How to Set Up an Electron Project with React

How to Set Up an Electron Project with React

I am assuming you meant Electron. If that is the case, this guide will show you how to set up a real Electron + React project from scratch, explain the moving parts in plain language, and give you a workflow you can actually use. Electron is a framework for building desktop apps with JavaScript, HTML, and CSS, and it bundles Chromium and Node.js so you can build apps that run on Windows, macOS, and Linux with one JavaScript codebase. React is a UI library for building interfaces from components.

This article uses a practical setup based on Electron Forge. Electron Forge is the official toolchain for packaging and distributing Electron apps, and its React guide is built around the Webpack template. Electron Forge also offers a Vite template, but its docs label Vite support experimental as of v7.5.0, so for a beginner-friendly setup that follows the official React guide closely, Webpack is the safer path here.

If you are new to this stack, do not worry about understanding everything at once. The point is to build confidence step by step. Maya, a junior developer, does not need to understand every build flag on day one. Omar, a freelancer, does not need to know the entire Electron ecosystem before shipping his first desktop utility. What matters is learning the shape of the project, the role of each file, and the exact command sequence that gets you from an empty folder to a working desktop app.


What you are actually building

An Electron app is not “just a React app.” It has two worlds:

  • the main process, which controls the desktop app window and talks to the operating system

  • the renderer process, which displays the interface, and in this guide that interface is built with React

That split is one of the most important things to understand early. Electron itself is designed to let you use web technologies for desktop apps, while React handles the UI layer inside the window.

Here is the mental model:

Here is the mental model

flowchart LR
    A[Electron Main Process] --> B[Creates and manages windows]
    B --> C[Loads React UI in Renderer]
    C --> D[User clicks buttons]
    D --> E[React updates interface]
    E --> F[Optional IPC to Main Process]

The main process is like the director. React is like the actor on stage. IPC, which means inter-process communication, is how they pass messages to each other when the UI needs something from Electron, such as opening a file, showing a dialog, or reading system information.


Why Electron Forge is a good starting point

The Electron team recommends starting from a scaffolded project instead of wiring everything by hand. Electron Forge provides built-in templates, including Webpack, TypeScript variants, and Vite variants, and its import tooling can also bring existing projects into the Forge workflow. Its configuration is centralized in a config.forge object or a forge.config.js file.

For this guide, that means one simple idea: start with the official Webpack template, then layer React on top. Electron Forge’s React guide says this setup does not require a complicated boilerplate, and it was tested with React 18, Babel 7, and Webpack 5.

That matters because beginners often get stuck when they try to combine too many tools at once. If you begin with the official template, you avoid a lot of hidden setup pain.


The setup we will use

We will build a project with:

  • Electron

  • Electron Forge

  • Webpack

  • Babel

  • React

  • React DOM

Electron Forge’s React guide tells you to add @babel/core, @babel/preset-react, and babel-loader so JSX and other React features work correctly, and then install react and react-dom in your dependencies. The guide also shows a minimal renderer example using createRoot from react-dom/client.

Here is the structure we are aiming for:

my-electron-react-app/
├─ src/
│  ├─ main.js
│  ├─ preload.js
│  ├─ renderer.js
│  ├─ App.jsx
│  └─ index.css
├─ .gitignore
├─ forge.config.js
├─ package.json
└─ webpack.rules.js

You do not have to use exactly this file layout forever. It is a clean learning layout that makes the project easier to read.


Step 1: Create the Electron project

The official Electron Forge Webpack template command is:

npx create-electron-app@latest my-new-app --template=webpack

After the template is created, the Forge docs say to run npm start in the generated directory. (electronforge.io)

So the first move is simple:

npx create-electron-app@latest my-electron-react-app --template=webpack
cd my-electron-react-app
npm start

At this point, you should already have a basic Electron app running. Electron’s own tutorial also explains that Electron apps are scaffolded using npm with package.json as the entry point, and that the development workflow is meant to get a working app running from the terminal.

If you are on Windows, Electron’s tutorial warns against using WSL for the first-app walkthrough because it can cause execution issues. That warning is specific to the tutorial, but it is worth keeping in mind if something strange happens during setup.


Step 2: Understand the generated project

The template gives you a base Electron project. That base already knows how to create a window and run a desktop app. Your job is to add React as the front-end layer.

Electron’s documentation explains that Electron apps are developed with a package.json entry point, and its installation guide says Electron is typically installed as a development dependency. If you are starting from a manual setup instead of a template, the preferred installation command is npm install electron --save-dev.

The important idea is that the Electron binary itself is bundled into the app during packaging, so it is not treated like a normal production web dependency. That is why the Electron docs describe it as a dev dependency.


Step 3: Install React and Babel support

Electron Forge’s React guide says to add the following development dependencies so JSX and React features are handled properly:

npm install --save-dev @babel/core @babel/preset-react babel-loader

Then install React itself:

npm install react react-dom

Those commands come directly from the official Electron Forge React guide.

Now let us write the config that lets Webpack understand JSX.


Step 4: Update webpack.rules.js

Electron Forge’s React guide shows a loader rule that matches .js and .jsx files and uses babel-loader with @babel/preset-react. A minimal version looks like this: (electronforge.io)

module.exports = [
  // ... existing loader config ...
  {
    test: /\.jsx?$/,
    use: {
      loader: 'babel-loader',
      options: {
        exclude: /node_modules/,
        presets: ['@babel/preset-react'],
      },
    },
  },
  // ... existing loader config ...
];

This tells Webpack to process React-style JavaScript syntax correctly.

A small human-friendly way to think about it:

  • Webpack is the packer

  • Babel is the translator

  • React is the UI authoring style

Without Babel, JSX would not be understood. Without Webpack, the app would not be bundled cleanly for Electron. Without React, you would be writing the UI by hand in plain DOM code.


Step 5: Create the main process file

The main process is where your Electron app starts. It creates windows and manages the app lifecycle. Electron’s docs describe that APIs that must run before the ready event should be called synchronously in the top-level main-process context.

Here is a simple src/main.js example:

const { app, BrowserWindow } = require('electron');
const path = require('path');

function createWindow() {
  const win = new BrowserWindow({
    width: 1100,
    height: 750,
    minWidth: 900,
    minHeight: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
    },
  });

  win.loadURL(`file://${path.join(__dirname, 'index.html')}`);
}

app.whenReady().then(() => {
  createWindow();

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

This is a standard Electron-style window lifecycle example. The code itself is ours, but it follows the official Electron model of creating the app window once the app is ready.

A short schema for this process:

A short schema for this process:

flowchart TB
    A[app.whenReady] --> B[createWindow]
    B --> C[BrowserWindow opens]
    C --> D[React loads in renderer]
    D --> E[User interaction begins]

Step 6: Add a preload script

A preload script is a safe bridge between the main process and the renderer. This becomes important when React needs to call Electron APIs without exposing everything to the UI. Electron’s docs have a dedicated tutorial path for preload scripts, which shows that they are part of the normal Electron app structure.

A simple src/preload.js might look like this:

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  sendMessage: (message) => ipcRenderer.send('message:send', message),
  onMessage: (callback) => ipcRenderer.on('message:reply', (_, value) => callback(value)),
});

This is one of the most useful patterns in Electron apps because it keeps the renderer cleaner and safer.

If you are teaching this to a beginner, this is a good line to remember: the preload script is the controlled hallway between your React UI and Electron’s power.


Step 7: Create the React entry point

Electron Forge’s React guide gives a minimal React example using createRoot from react-dom/client, then rendering a component. It also shows importing that React code into the renderer file.

Here is a more complete src/App.jsx:

import React, { useState } from 'react';

export default function App() {
  const [name, setName] = useState('');
  const [message, setMessage] = useState('Welcome to Electron + React');

  const handleClick = () => {
    const displayName = name.trim() || 'friend';
    setMessage(`Hello, ${displayName}! Your desktop app is running.`);
  };

  return (
    <main className="app-shell">
      <section className="card">
        <h1>Electron + React</h1>
        <p>{message}</p>

        <div className="field">
          <label htmlFor="name">Your name</label>
          <input
            id="name"
            value={name}
            onChange={(e) => setName(e.target.value)}
            placeholder="Type your name"
          />
        </div>

        <button onClick={handleClick}>Say hello</button>
      </section>
    </main>
  );
}

And the renderer entry file src/renderer.js:

import * as React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './index.css';

const rootElement = document.getElementById('root');
const root = createRoot(rootElement);

root.render(<App />);

That is the simplest human-friendly version of the React renderer. One component, one root, one clear purpose.


Step 8: Add the HTML shell

React still needs a container in the page. Your Electron renderer can load an HTML file with a root element. A basic src/index.html might look like this:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>Electron React App</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

This is the bridge point where React mounts into the page.


Step 9: Add some styling

A desktop app should feel like a real product, not a blank tutorial page. Here is a clean src/index.css:

:root {
  font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
  color: #1f2937;
  background: #f3f4f6;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background: linear-gradient(180deg, #f9fafb 0%, #eef2ff 100%);
}

.app-shell {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
}

.card {
  width: min(100%, 640px);
  background: white;
  border-radius: 20px;
  padding: 32px;
  box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
}

.card h1 {
  margin-top: 0;
  font-size: 2rem;
}

.field {
  display: grid;
  gap: 8px;
  margin: 20px 0;
}

.field input {
  padding: 14px 16px;
  border: 1px solid #d1d5db;
  border-radius: 12px;
  font-size: 1rem;
}

button {
  padding: 14px 18px;
  border: none;
  border-radius: 12px;
  font-size: 1rem;
  cursor: pointer;
}

This is the kind of styling that makes the app feel calm and usable. People notice good spacing before they notice architecture.


Step 10: Confirm the package scripts

Electron Forge’s import documentation shows the standard scripts it wires up in package.json:

{
  "scripts": {
    "start": "electron-forge start",
    "package": "electron-forge package",
    "make": "electron-forge make",
    "publish": "electron-forge publish"
  }
}

Those scripts are part of the normal Forge workflow. start runs the app in development mode, package prepares a packaged app, make builds installers, and publish uploads release artifacts if configured.

That script layout is one of the reasons Electron Forge is so helpful. It gives you a development path and a packaging path in the same toolchain. The docs also note that Forge’s build pipeline can produce installers such as Squirrel.Windows, ZIP, and deb via electron-forge make.


Step 11: Configure Forge in forge.config.js

Electron Forge says its configuration can live in package.json under config.forge, or in a forge.config.js file. The docs also say that using JavaScript for the config is recommended because it allows conditional logic.

A simple forge.config.js:

module.exports = {
  packagerConfig: {},
  rebuildConfig: {},
  makers: [
    { name: '@electron-forge/maker-zip' },
    { name: '@electron-forge/maker-deb' },
    { name: '@electron-forge/maker-squirrel' },
  ],
  plugins: [],
};

That is enough to start learning the shape of Forge config. The official docs list packagerConfig, rebuildConfig, makers, publishers, plugins, hooks, buildIdentifier, and outDir as supported configuration properties.


A practical project structure

Here is a tidy structure for a React + Electron app:

my-electron-react-app/
├─ src/
│  ├─ main.js
│  ├─ preload.js
│  ├─ renderer.js
│  ├─ App.jsx
│  ├─ index.html
│  └─ index.css
├─ forge.config.js
├─ webpack.rules.js
├─ package.json
└─ .gitignore

This layout keeps the responsibilities clean:

  • main.js handles app windows and Electron logic

  • preload.js handles the bridge

  • renderer.js starts React

  • App.jsx contains UI components

  • index.css controls styles

That separation matters because desktop apps become hard to maintain when everything is thrown into one file.


What a simple IPC example looks like

Now let us make the app feel more real.

Suppose the renderer wants to send a message to the main process. Here is a simple Electron IPC setup.

Main process

const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');

function createWindow() {
  const win = new BrowserWindow({
    width: 1100,
    height: 750,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
    },
  });

  win.loadFile(path.join(__dirname, 'index.html'));
}

ipcMain.on('message:send', (event, message) => {
  event.reply('message:reply', `Main process received: ${message}`);
});

app.whenReady().then(createWindow);

Preload script

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  sendMessage: (message) => ipcRenderer.send('message:send', message),
  onReply: (callback) => ipcRenderer.on('message:reply', (_, value) => callback(value)),
});

React component

import React, { useEffect, useState } from 'react';

export default function App() {
  const [input, setInput] = useState('');
  const [reply, setReply] = useState('No message yet');

  useEffect(() => {
    window.electronAPI.onReply((value) => setReply(value));
  }, []);

  const handleSend = () => {
    window.electronAPI.sendMessage(input);
  };

  return (
    <section className="card">
      <h1>IPC Demo</h1>
      <p>{reply}</p>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="Send a message"
      />
      <button onClick={handleSend}>Send</button>
    </section>
  );
}

This is the moment many beginners finally understand why Electron is interesting. React draws the UI, but Electron gives the app powers that a normal browser page does not have.


A schema for IPC flow

A schema for IPC flow

sequenceDiagram
    participant U as User
    participant R as React Renderer
    participant P as Preload
    participant M as Electron Main

    U->>R: Type message and click Send
    R->>P: window.electronAPI.sendMessage()
    P->>M: ipcRenderer.send("message:send")
    M->>R: event.reply("message:reply")
    R->>U: Show response on screen

That diagram captures the message route in a way that sticks better than a paragraph alone.


How to think about safety

Electron apps can be powerful, so it is important to keep the renderer and main process separated. Electron’s tutorials and API docs emphasize lifecycle order and proper use of the main process. The preload pattern exists to help you expose only the APIs you actually need.

For a beginner, the safe default is:

  • contextIsolation: true

  • nodeIntegration: false

  • expose only a small API through preload

  • avoid giving the renderer unrestricted access to Node.js

That does not make an app perfect by itself, but it is a solid habit from day one.


How to run the app

Once the setup is done, the common Forge development command is:

npm start

That is the command the Forge docs show after initializing the template.

If you hit problems, make sure:

  • dependencies installed correctly

  • webpack.rules.js includes the React loader

  • renderer.js is loading the React root

  • the HTML file contains <div id="root"></div>

  • the main window points to the right file

These checks solve most beginner setup issues.


When you should use Vite instead

Electron Forge also offers a Vite template with the command:

npx create-electron-app@latest my-new-app --template=vite

But the docs say that as of Electron Forge v7.5.0, Vite support is experimental and future minor releases may contain breaking changes. (electronforge.io)

So how should you decide?

For a beginner article and a straightforward learning project, I would stay with Webpack because the official React integration guide is written for it and specifically tested with React 18, Babel 7, and Webpack 5.

For an advanced team that is already comfortable with Vite and wants faster iteration, the Vite template may still be attractive, but it is not the route I would choose for the cleanest first setup right now. That conclusion is an inference based on the official docs’ stable React guide and the experimental label on the Vite template.


What if you already have an Electron project?

If you already built an Electron app and want to bring it into Electron Forge, the official docs say you can use the Forge import command. The docs provide this flow: install @electron-forge/cli, then run npm exec --package=@electron-forge/cli -c "electron-forge import".

That can save time when you have an older project and want the benefit of Forge’s packaging pipeline. The docs also say Forge’s import workflow may migrate settings automatically, though some manual changes may still be necessary.


A beginner-friendly learning path

When people first learn Electron + React, they often jump straight to advanced features like auto-updating, native modules, or file system access. That is too much too soon.

A better learning path is:

  1. run the app

  2. render a React component

  3. style the UI

  4. add a button

  5. send a message with IPC

  6. receive a response in React

  7. package the app

  8. only then add advanced features

That order keeps the experience human. You gain one win at a time instead of drowning in setup complexity.


A small full example

Here is a compact end-to-end example that combines the ideas above.

src/main.js

const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');

function createWindow() {
  const win = new BrowserWindow({
    width: 1000,
    height: 700,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
    },
  });

  win.loadFile(path.join(__dirname, 'index.html'));
}

ipcMain.on('say-hello', (event, name) => {
  const safeName = name && name.trim() ? name.trim() : 'friend';
  event.reply('say-hello-response', `Hello, ${safeName}!`);
});

app.whenReady().then(createWindow);

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});

src/preload.js

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  sayHello: (name) => ipcRenderer.send('say-hello', name),
  onHelloResponse: (callback) =>
    ipcRenderer.on('say-hello-response', (_, message) => callback(message)),
});

src/App.jsx

import React, { useEffect, useState } from 'react';

export default function App() {
  const [name, setName] = useState('');
  const [reply, setReply] = useState('Waiting for your name...');

  useEffect(() => {
    window.electronAPI.onHelloResponse((message) => {
      setReply(message);
    });
  }, []);

  return (
    <main className="app-shell">
      <section className="card">
        <h1>Electron React Starter</h1>
        <p>{reply}</p>

        <div className="field">
          <label htmlFor="name">Name</label>
          <input
            id="name"
            value={name}
            onChange={(e) => setName(e.target.value)}
            placeholder="Type your name"
          />
        </div>

        <button onClick={() => window.electronAPI.sayHello(name)}>
          Say hello from Electron
        </button>
      </section>
    </main>
  );
}

src/renderer.js

import * as React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './index.css';

createRoot(document.getElementById('root')).render(<App />);

src/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Electron React Starter</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

This is enough to make the app feel real. The button works, the UI updates, and the bridge between React and Electron is visible.


A schema for the file roles

A schema for the file roles

flowchart TB
    A[main.js] --> B[Creates Electron window]
    C[preload.js] --> D[Exposes safe bridge]
    E[renderer.js] --> F[Mounts React]
    G[App.jsx] --> H[Shows UI]
    I[index.css] --> H

This map helps beginners remember where each piece belongs.


Common mistakes beginners make

A lot of first-time Electron + React setup problems come from small details.

Forgetting the root element

If index.html does not contain a root element, React has nowhere to mount.

Missing Babel loader rules

If JSX is not being transformed, the app will fail to build.

Mixing main-process code into React components

The main process and renderer are separate worlds.

Exposing too much in preload

Keep the bridge small and intentional.

Ignoring the Forge scripts

Use the scripts Forge gives you instead of inventing a custom workflow too early.

These are beginner mistakes, but even experienced developers make them when they are moving fast.


How this feels in real life

Imagine three people building the same app.

Amina is the designer. She changes spacing and colors in React.
Youssef is the backend-minded developer. He adds IPC handlers in the main process.
Sara is the product owner. She tests the desktop app on Windows and macOS.

Electron + React works well in this kind of human workflow because everyone can focus on the layer they understand best:

  • design in React

  • desktop behavior in Electron

  • collaboration in the Git repository

  • packaging in Electron Forge

That is why this stack is popular. It fits the way teams actually work.


If you want to package the app later

Electron Forge’s docs say its build pipeline can produce installers and packaged outputs via electron-forge make, and its configuration supports packaging, makers, publishers, and hooks.

That means your learning does not stop at “the app runs on my machine.” You can move toward distributable desktop software with the same project.

A simple sequence is:

npm start
npm run package
npm run make

The exact packaging behavior depends on your Forge config, makers, and target platform. Forge’s docs also note that configuration can be placed in package.json or in forge.config.js. (electronforge.io)


Final advice for beginners

Do not try to learn Electron, React, Webpack, Babel, IPC, and packaging all in one sitting. That is how people get overwhelmed. Learn the stack in layers.

Start with this order:

  • create the Electron app

  • render one React component

  • understand main vs renderer

  • add a preload bridge

  • send one message through IPC

  • package the app

Once you can do those six things, the rest becomes much easier.

Electron gives you the desktop shell. React gives you the interface. Electron Forge gives you the project structure and packaging workflow. Together, they let you build real desktop applications with web skills you already know.

And that is the part many people miss: this is not just setup. It is a full path from idea to actual desktop app. A small app today becomes a portfolio piece tomorrow, and later it can become a real product that people install and use.