Electron with React




You've got a React app that works in the browser, and now someone wants it on the desktop. Users want offline access, native file dialogs, tray icons, or a signed installer they can hand to IT. So you reach for Electron with React, get a window open in an afternoon, and then discover the hard part isn't rendering components. It's deciding what the renderer is allowed to ask the operating system to do.
That boundary is where most Electron apps succeed or fail. Treat React as untrusted presentation code, keep privileged work in the main process, and expose a small, typed bridge between them. Do that from day one and the rest, packaging, updates, performance tuning, becomes manageable engineering instead of a rewrite.
Table of Contents
Teams choose this stack for a practical reason. They already have web engineers who know React, and they don't want three separate native codebases. Electron lets you ship one application codebase to Windows, macOS, and Linux, with React handling the component based interface and Electron handling windows, menus, filesystem access, and application lifecycle.
That reuse is real, but it isn't free. You're shipping Chromium and Node.js with your product, which means larger installers and higher baseline memory use than a native app. For a document editor, internal operations tool, media client, or developer utility, that trade is usually worth it. For a tiny background utility that must stay resident all day on low end hardware, it may not be.
Electron isn't an experiment. Its first commit was made on March 13, 2013, when the project was initially called Atom Shell and built as the runtime for GitHub's Atom editor, as outlined in Electron Forge's React integration guide. It combined Chromium with Node.js so developers could build desktop software with web technologies while retaining access to native capabilities.
Electron 1.0 arrived in 2016, followed by Electron 2.0 in 2018 with semantic versioning. Early adopters beyond Atom included Slack, GitKraken, and WebTorrent. That history matters because desktop distribution punishes immature tooling. Code signing, auto update, crash handling, and OS upgrades are long tail problems, and a runtime that's survived a decade of them has documented answers for most.
You still own the unglamorous parts. You'll manage BrowserWindow security settings, IPC validation, update channels, and platform specific packaging quirks yourself. React doesn't solve any of that. What React gives you is a productive UI layer, a huge component ecosystem, and a hiring pool that already knows your frontend stack.
Build the app as if the renderer will one day render compromised content. If your design stays safe under that assumption, routine bugs stay routine.
The question to answer before scaffolding is simple. Does your product gain enough from desktop integration and cross platform reuse to justify shipping a browser runtime? If yes, structure it properly from the first commit.
Start with tooling that rebuilds fast and fails loudly. For new projects, Vite is the sensible default for the renderer. Its dev server starts quickly, hot module replacement is reliable, and its build output is easy to point Electron at in both development and production.
Create React App can still work if you're migrating an existing CRA codebase and don't want a parallel migration. Be honest about the cost, though. Ejecting or working around its webpack configuration to coordinate the Electron main process, preload bundling, and production file loading tends to consume the time you thought you saved. If you're starting fresh, don't inherit that fight.
Keep the three execution contexts physically apart so imports can't drift across the boundary by accident.
That last folder is where your IPC contract types live. If a type needs fs to compile, it doesn't belong in shared.
Your package.json needs three reliable workflows. One script runs the Vite dev server and Electron together, waiting for the server before opening a window. One watches and restarts Electron when main or preload code changes, since HMR only covers the renderer. One produces a production bundle and packages it, so you test the same artifact users will install.
Install TypeScript from the start, plus your test runner and a linter configured to ban Node imports in the renderer folder. If you're still choosing a visual starting point for the renderer, you can browse React template options and then strip whatever you pick down to plain components that fit the layout above. Teams that need senior help setting this foundation up correctly often look at dedicated React development support rather than retrofitting structure later.
Practical rule: If a new hire can import
ipcRendererinto a component without a lint error, your scaffold isn't done.
Finish this stage with one boring milestone. The app boots, React changes hot reload, main process changes restart the window, and a packaged build opens without a dev server.
Electron uses a multi process architecture with one main process and one or more renderer processes. The official Electron process model documentation is clear about the split. The main process is the entry point, runs in Node.js, and can use Node and Electron APIs. Renderer processes display web content and, by default, don't have direct access to Node.js or Electron modules.
Your React app lives in a renderer. That means a component should never read a file, open a socket to a local service with high trust, or call shell.openExternal directly. It asks. The main process decides.
Create windows with nodeIntegration: false, contextIsolation: true, sandboxing enabled, and webSecurity retained. These aren't hardening extras for later. They're the baseline that makes renderer bugs containable.
Context isolation has been enabled by default since Electron 12, and the context isolation guide recommends it for all applications. With it on, your preload script runs in a separate JavaScript context from page code, so the preload's window object isn't the object React sees. React can't reach in and mutate privileged objects you forgot to hide.
Since Electron 20, preload scripts are also sandboxed by default, so they no longer get a full Node.js environment. Old tutorials that import arbitrary Node modules in preload will break, and that's a feature. The preload tutorial describes the supported flow. Define a handler in main, invoke it from preload, expose a scoped wrapper to the renderer.
Think of preload as a receptionist, not a vault. It forwards specific requests and returns specific answers. It shouldn't parse files, hold secrets, or contain branching business logic.
saveSettings, selectFile, openLogFolder, each mapped to one handlerThe failure mode to avoid is the convenience shortcut, exposing window.electron with the whole ipcRenderer attached because a deadline is close. At that point any cross site scripting flaw in a dependency, a markdown preview, or a user supplied HTML view can send arbitrary messages to handlers you wrote assuming only your UI would call them.
Keep the bridge narrow and the renderer stays honest. React developers get a small typed API they can mock in a browser for Storybook and unit tests, which is a genuine productivity win, not just a security posture.
IPC is your app's internal API, and it deserves the same discipline you'd give a public one. Electron documents two renderer to main patterns, covered in the official IPC tutorial. Pick based on whether the caller needs a result.
| Pattern | Direction | Returns a value | Typical use case |
|---|---|---|---|
ipcRenderer.send with ipcMain.on |
Renderer to main, one way | No | Fire and forget events like logging, analytics pings, window controls |
ipcRenderer.invoke with ipcMain.handle |
Renderer to main, request and response | Yes, a Promise | Reading settings, selecting files, running privileged operations |
webContents.send with a preload listener |
Main to renderer, one way | No | Update progress, deep link events, menu commands pushed to React |
Default to invoke and handle for most React calls. Promises map cleanly onto async event handlers and React Query style data fetching, and errors thrown in main reject in the renderer where your UI can present them.
A React component calls window.api.saveSettings(settings). That function exists because preload created it with contextBridge.exposeInMainWorld, and inside it calls ipcRenderer.invoke('settings:save', settings). In main, ipcMain.handle('settings:save', handler) receives the event and payload, validates both, writes to disk, and returns a typed result.
Type the surface once in your shared folder, for example an interface with saveSettings(input: SettingsInput): Promise<SaveResult> and selectFile(options: SelectFileOptions): Promise<string | null>. Declare that interface on window.api in the renderer so TypeScript enforces it at every call site. When a channel has no typed wrapper, it doesn't get used. That single rule prevents most IPC sprawl, and it's the same contract thinking behind sound API design practices on the server side.
Electron's official security checklist recommends disabling Node.js integration for remote content, enabling context isolation and sandboxing, enforcing a restrictive Content Security Policy, limiting navigation and new window creation, and avoiding untrusted input with shell.openExternal. Apply that mindset inside every handler too.
One renderer XSS should cost you a bug report, not arbitrary code execution on a user's machine. The difference is almost always how wide your preload API is.
Add capabilities one at a time, each with a handler, a preload wrapper, a type, and tests. Track concrete release criteria, such as every handler covered by negative tests with malformed input, zero renderer access to Node globals, and zero high severity findings from your dependency and security scanning before you ship.
Don't start by memoizing components. Start by measuring a packaged build, because development mode lies to you. React's development build does extra work, Vite serves unbundled modules, and Electron may behave differently once your app loads from disk inside an installed artifact.
Electron's performance guidance targets three resource classes, memory, CPU, and disk, and emphasizes preserving input responsiveness while reducing unnecessary work, without publishing universal benchmarks. That's the right framing. There is no generic pass mark for your product, only regression against your own baseline on hardware your users own.
Record the same numbers, on representative Windows, macOS, and Linux machines, for every release candidate.
Profile with both Chromium DevTools and React Profiler. DevTools tells you whether time went to layout, scripting, or IPC serialization. React Profiler tells you which subtree rendered and why. Guessing which layer is slow wastes the most engineering time in Electron apps.
Code split your routes so a settings screen or admin panel doesn't load before first paint. Virtualize long lists instead of mounting thousands of rows at startup. Debounce search input before it triggers filtering or IPC. Move CPU heavy parsing, image work, or archive handling off the renderer, either to the main process with care not to block its event loop, or to a worker.
Memoize only where the profiler shows an expensive subtree re rendering with identical props. Blanket React.memo adds comparison cost and code noise while hiding the underlying problem, which is usually state placed too high in the tree or a context value that changes identity on every render.
Watch IPC volume closely. Each call serializes data across a process boundary. Fetching a record per row, or polling main for status on a tight interval, turns a cheap looking loop into sustained CPU use. Batch requests, cache settings in the renderer after first load, and prefer main pushing state changes over React polling for them.
The common pitfalls are predictable. Teams benchmark an empty window, mount their largest dataset during startup to make navigation feel instant, or measure only on the lead developer's machine. Set a release gate instead. A build that regresses startup, input latency, memory, or CPU against your recorded baseline doesn't ship until someone explains why.
Shipping starts when the app works on a machine that isn't yours. Budget real time for this, because platform signing rules and installer behavior are where first time Electron teams lose weeks.
Electron Forge offers an integrated path from scaffolding through packaging and publishing, and it's a comfortable default if your needs are standard. electron-builder remains a strong choice when you need finer control over installer formats, signing configuration, or update channels. Either can produce installers for Windows, macOS, and Linux from the same codebase. Pick one early and stop reevaluating, because switching packagers late means redoing signing and update plumbing.
macOS builds need signing and notarization before Gatekeeper will let users open them without warnings, and Windows users trust signed installers more readily. Set certificates up in CI as protected secrets, never on a developer laptop as the only copy.
Auto update runs through the main process. Main checks your update feed, downloads in the background, and exposes status to React through your preload bridge, such as onUpdateReady(callback) and restartToUpdate(). The renderer should never download or execute an updater payload itself. Verify signatures on update artifacts the same way you verify the initial installer, since your update channel is effectively remote code delivery to every installed machine.
Unit test preload validation and main process handlers in isolation, including malformed payloads and unexpected senders. Those tests are fast and catch contract drift between the shared types and runtime checks.
For end to end coverage, drive the packaged app with a tool like Playwright rather than testing only the Vite dev server in a browser. Packaged builds exercise your production file loading, preload path resolution, CSP, and IPC wiring. A surprising share of Electron bugs only exist in that configuration, including asset paths that worked over HTTP and break over the file protocol.
Wire those checks into a pipeline so releases are boring and repeatable. If your team is formalizing that pipeline, a primer on continuous integration fundamentals helps align everyone on what each stage should guarantee.
An Electron with React app succeeds or fails at the boundary between processes. Keep the renderer sandboxed with Node integration off and context isolation on. Expose a narrow, typed contextBridge API where each function is one intended capability. Validate the sender and arguments of every IPC handler as if the caller were hostile, because one day a dependency bug may make it hostile. Then measure performance on packaged builds and defend your baseline release after release.
Accept the tradeoffs with open eyes. You ship Chromium, so installers are larger and baseline memory is higher than native. You own signing, notarization, and the update channel forever, not just at launch. In return you get one React codebase across the three major desktop platforms and a mature runtime with a decade of production history behind it.
If your team needs senior engineers who've already shipped Electron and React products through signing, auto update, and store distribution, HireDevelopers.com can match you with pre vetted specialists, with a shortlist within 24 hours and flexible month to month engagements. Book a consultation, describe your desktop roadmap, and interview candidates who can strengthen your process boundary and release pipeline from week one.
Most SQL interview processes are broken because they reward the wrong behavior. A candidate memorizes the difference between DELETE and TRUNCATE, recites it on cue, and walks out looking sharp. Meanwhile, nobody has learned whether they can untangle a messy join, explain a bad execution plan, or keep a reporting query from chewing through your...
Discover how nearshore software development can reduce costs, speed up projects, and connect you with expert partners. Learn more today!
Planning a software project is about forcing brutal honesty about what you’re building, why, and what resources you actually have—before a single line of code gets written. It’s less about a perfect document and more about getting everyone rowing in the same direction. Why Most Software Project Plans Fail Before They Start Let’s be honest:...