Create an app with Electron and React

By

Quick start for an Electron desktop app with React using electron-vite or Electron Forge. Create React App is deprecated.

~~~

When I first used Electron in 2015 it was not yet clear that it would be so pervasive in modern apps, and I was kind of shocked by the resulting app size.

But Electron is clearly here to stay, and it’s not mandatory that your app should feel slow and consume tons of memory, like VS Code demonstrates every day to me.

This post started in 2017 as a quick start with create-react-app, foreman and a hand-written script that waited for the React dev server before launching Electron. That setup is dead: Create React App was deprecated in February 2025 and the React team tells you to use a framework or a build tool like Vite instead. So here is the same thing, done the way you’d do it today, with React 19 and Electron 44 (the current stable line as of October 2026).

You need Node.js installed, the LTS release is fine. Nothing else, no global packages.

Option 1: electron-vite

electron-vite is a build tool that runs Vite for the three parts of an Electron app: the main process, the preload script and the renderer. The current major is 6. It comes with a scaffolding tool that creates the whole project for you:

npm create @quick-start/electron@latest my-app -- --template react

The --template react flag picks the React (JavaScript) template. There is also react-ts if you want TypeScript. Then the tool asks three yes/no questions: TypeScript, the Electron updater plugin, and a download mirror for the Electron binary. Answering no to all three is fine to start.

This is what you get:

my-app/
├── electron.vite.config.mjs
├── electron-builder.yml
├── package.json
└── src/
    ├── main/index.js        # the Electron main process
    ├── preload/index.js     # the bridge between main and renderer
    └── renderer/
        ├── index.html
        └── src/
            ├── main.jsx     # createRoot(...).render(<App />)
            └── App.jsx

The renderer is a normal Vite + React app. src/renderer/src/main.jsx is the usual createRoot call you’d find in any React 19 project. The main process, in src/main/index.js, creates a BrowserWindow and, in development, loads the URL of the Vite dev server so you get hot module replacement in the window.

Enter the project and install:

cd my-app
npm install
npm run dev

npm run dev runs electron-vite dev. That starts Vite and opens the Electron window. Edits to the React code show up immediately, thanks to HMR. Edits to the main process or the preload script do not: by default electron-vite does not watch those files. Run it with the watch flag, and it rebuilds and restarts Electron when they change:

npx electron-vite dev --watch

I would change the dev script in package.json to include --watch.

A fresh project starts on Electron 44, the current major. Run npm outdated or check package.json when you want to know if a newer major has shipped.

For packaging, the template sets up electron-builder and gives you npm run build:mac, npm run build:win and npm run build:linux. Each one runs electron-vite build first, then electron-builder to produce the installer.

Option 2: Electron Forge

Electron Forge is the toolkit maintained by the Electron team itself. It covers scaffolding, packaging and publishing. The current release is Forge 8, and it needs Node.js 22.17 or newer. If yours is older, update Node.js first.

Forge has one Vite template, with a --typescript flag if you want TypeScript. There is still no React template. So you scaffold with Vite and add React yourself:

npx create-electron-app@latest my-app --template=vite
cd my-app
npm install react react-dom @vitejs/plugin-react

The Vite template gives you vite.renderer.config.mjs, src/renderer.js, src/main.js, and src/preload.js. Add the React plugin in the renderer config:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
})

Vite wants a .jsx file for JSX, so rename src/renderer.js to src/renderer.jsx. In index.html at the project root, add a root node and change the script to the new name:

<div id="root"></div>
<script type="module" src="/src/renderer.jsx"></script>

Put the UI in src/App.jsx:

export function App() {
  return <h1>Hello from React</h1>
}

Mount it from src/renderer.jsx:

import { createRoot } from 'react-dom/client'
import { App } from './App.jsx'

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

Run it with:

npm start

Forge handles packaging with npm run make, which produces platform-specific distributables. The template configures a .zip on macOS, a Squirrel installer on Windows, .deb and .rpm on Linux, and you can add a DMG maker. It also has publishers for GitHub releases and other targets. The command is electron-forge release now. publish still works as a deprecated alias.

Talking to the main process

The React tree runs in the renderer. It cannot import fs or call Electron APIs. That work lives in the main process.

A preload script sits in the middle. It publishes a small API on window with contextBridge.exposeInMainWorld. The renderer calls that API. The API talks to main with ipcRenderer.invoke. Main answers with ipcMain.handle.

Context isolation is on by default. Node integration in the renderer is off. Leave both that way. The page in the window never gets Node, and it cannot send arbitrary IPC.

On the Forge Vite template the preload file is src/preload.js. electron-vite puts the same idea in src/preload/index.js.

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

contextBridge.exposeInMainWorld('appInfo', {
  version: () => ipcRenderer.invoke('app:version'),
})

Expose one function per action. Don’t hand the renderer the whole ipcRenderer.

In the main process, handle that channel when the app is ready. On Forge that is src/main.js:

import { app, ipcMain } from 'electron'

app.whenReady().then(() => {
  ipcMain.handle('app:version', () => app.getVersion())
  createWindow()
})

Keep the rest of the template’s whenReady block. You’re only adding the handle line.

Then a React component can ask for the version:

import { useEffect, useState } from 'react'

export function App() {
  const [version, setVersion] = useState('')

  useEffect(() => {
    window.appInfo.version().then(setVersion)
  }, [])

  return <p>Electron {version}</p>
}

invoke returns a Promise, so this fits useEffect without extra glue.

Which one?

If you want to start writing React right away, electron-vite. The React template is there, HMR works out of the box and electron-builder is already configured.

If you’d rather stay on the official Electron tooling, Forge. You do a bit more setup, but the packaging and publishing story is the one the Electron docs describe.

electron-react-boilerplate, which I recommended in an earlier version of this post, is still maintained and is a fine third option. It’s a complete project template rather than a build tool: React Router, TypeScript and tests are already wired up. Under the hood it moved from webpack to electron-vite, and its npm start script runs electron-vite dev --watch, the same command I suggested above. Start from it if you want all of that set up on day one.

Tagged: React · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about react: