
Electron Best Practices
- 734 installs
- 133 repo stars
- Updated February 24, 2026
- jwynia/agent-skills
electron-best-practices is an agent skill at version 1.0 that guides secure Electron plus React development with electron-vite, type-safe IPC, packaging, and Playwright testing for developers shipping desktop application
About
electron-best-practices is a version 1.0 agent skill guiding secure Electron 20+ desktop app development with React, electron-vite, and Electron Forge packaging. The skill enforces context isolation, sandbox mode, and disabled nodeIntegration, routing all main-renderer communication through contextBridge invoke/handle patterns with typed IpcChannelMap definitions. Bundled asset templates cover electron.vite.config.ts, forge.config.js, Playwright config, typed IPC handlers, preload scripts, and multi-window examples. Developers reach for electron-best-practices when scaffolding production desktop apps requiring code signing, notarization, CSP headers, and Playwright end-to-end tests. The skill explicitly excludes Tauri projects and Electron versions below 20 where security defaults differ.
- Unified Vite config for all three Electron processes in one file
- Automatic externalizeDepsPlugin usage for main and preload
- React plugin + proper HTML entry for the renderer process
- Cross-process path aliasing with @shared resolution
- Environment-aware minification, sourcemaps, and rollupOptions
Electron Best Practices by the numbers
- 734 all-time installs (skills.sh)
- +13 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #473 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jwynia/agent-skills --skill electron-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 734 |
|---|---|
| repo stars | ★ 133 |
| Security audit | 3 / 3 scanners passed |
| Last updated | February 24, 2026 |
| Repository | jwynia/agent-skills ↗ |
How do you configure secure Electron React IPC?
Generate production-grade electron-vite configurations that correctly wire main, preload, and renderer processes with React, path aliases, and environment-aware o
Who is it for?
Developers building production Electron 20+ desktop apps with React who need secure IPC, electron-vite wiring, and packaging guidance in agent workflows.
Skip if: Teams building Tauri or pure web apps with no desktop packaging, signing, or multi-process Electron requirements.
When should I use this skill?
A developer asks to set up electron-vite, configure contextBridge IPC, package Electron apps, or implement Electron security patterns with React.
What you get
electron.vite.config.ts, typed preload contextBridge API, IPC handler map, Forge packaging config, and Playwright test setup.
- electron.vite.config.ts
- typed preload script
- Forge packaging configuration
By the numbers
- Skill version 1.0 with bundled config and template asset files
- Targets Electron 20+ security defaults across three process types
- Covers main, preload, and renderer process configuration patterns
Files
Electron + React Best Practices
Guide AI agents in building secure, production-ready Electron applications with React. This skill provides security patterns, type-safe IPC communication, project setup guidance, packaging and code signing workflows, and tools for analysis, scaffolding, and type generation.
When to Use This Skill
Use this skill when:
- Generating Electron main, preload, or renderer process code
- Configuring electron-vite or Electron Forge
- Setting up IPC communication between processes
- Implementing security patterns (contextBridge, sandbox, CSP)
- Packaging, signing, and notarizing desktop applications
- Testing Electron apps with Playwright
- Designing multi-window architectures
Do NOT use this skill when:
- Building Tauri apps (different paradigm, use Tauri-specific guidance)
- Building pure web apps with no desktop requirements
- Targeting Electron versions below 20 (security defaults differ)
- Using non-React renderer frameworks (use framework-specific skills)
Core Principles
1. Security First Architecture
Modern Electron security relies on three defaults that became standard in Electron 20+: context isolation, sandbox mode, and nodeIntegration disabled. Disabling any of them allows XSS attacks to escalate to full remote code execution. All main-renderer communication must flow through contextBridge:
// preload.ts - SECURE pattern
contextBridge.exposeInMainWorld('electronAPI', {
loadPreferences: () => ipcRenderer.invoke('load-prefs'),
saveFile: (content: string) => ipcRenderer.invoke('save-file', content),
onUpdateCounter: (callback: (value: number) => void) => {
const handler = (_event: IpcRendererEvent, value: number) => callback(value);
ipcRenderer.on('update-counter', handler);
return () => ipcRenderer.removeListener('update-counter', handler);
}
});Set Content Security Policy via HTTP headers for apps loading local files, restricting script sources to 'self'.
2. Type-Safe IPC Communication
The invoke/handle pattern is preferred over send/on for request-response communication, providing proper async/await semantics and error propagation. For typed channels, use a mapped type pattern:
type IpcChannelMap = {
'load-prefs': { args: []; return: UserPreferences };
'save-file': { args: [content: string]; return: { success: boolean } };
};For complex applications, electron-trpc provides full type safety using tRPC's router pattern with Zod validation:
export const appRouter = t.router({
greeting: t.procedure
.input(z.object({ name: z.string() }))
.query(({ input }) => `Hello, ${input.name}!`),
});Error handling across the IPC boundary requires attention because Electron only serializes the message property of Error objects. Wrap responses in a { success, data, error } result type to preserve full error context.
3. Modern Project Setup
The recommended stack uses electron-vite for development and Electron Forge for packaging. electron-vite provides a unified configuration managing main, preload, and renderer processes with sub-second dev server startup and instant HMR. Electron Forge uses first-party Electron packages for signing and notarization.
src/
├── main/ # Main process (Node.js environment)
│ ├── index.ts
│ └── ipc/ # IPC handlers
├── preload/ # Secure bridge via contextBridge
│ ├── index.ts
│ └── index.d.ts # TypeScript declarations for exposed APIs
└── renderer/ # React application (pure web, no Node access)
├── src/
└── index.html4. React Integration Patterns
React 18's concurrent features work normally in Electron's Chromium-based renderer. Strict Mode's double-invocation of effects catches IPC listener leaks that would otherwise cause memory issues. Always return cleanup functions from effects that register IPC listeners:
useEffect(() => {
const cleanup = window.electronAPI.onUpdateCounter((value) => {
setCount(value);
});
return cleanup;
}, []);For multi-window applications, the main process should serve as the single source of truth for shared state. Use electron-store for persistence combined with IPC broadcasting so any window's mutation updates all others.
Quick Reference
| Category | Prefer | Avoid |
|---|---|---|
| Security | contextBridge.exposeInMainWorld() | nodeIntegration: true |
| IPC | invoke/handle pattern | send/on for request-response |
| Preload | Typed function wrappers | Exposing raw ipcRenderer |
| Build tool | electron-vite | webpack-based toolchains |
| Packaging | Electron Forge | Manual packaging |
| State | Zustand + electron-store | Redux for simple apps |
| Testing | Playwright E2E | Spectron (deprecated) |
| Updates | electron-updater | Manual update checks |
| Signing | CI-integrated code signing | Unsigned releases |
| CSP | HTTP headers, 'self' only | No CSP |
| Error handling | Result type {success, data, error} | Raw Error across IPC |
| Multi-window | Main process as state hub | Direct window-to-window |
Code Generation Guidelines
When generating Electron code, follow these patterns:
BrowserWindow Creation
const win = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
},
});Always enable contextIsolation and sandbox. Never enable nodeIntegration. The preload path must resolve to the built output location.
IPC Handler Module
export function registerFileHandlers(): void {
ipcMain.handle('save-file', async (_event, content: string) => {
try {
await fs.writeFile(filePath, content);
return { success: true, data: filePath };
} catch (err) {
return { success: false, error: (err as Error).message };
}
});
}Group related handlers into modules. Use the result type pattern for all return values. Validate all arguments received from the renderer process.
Common Anti-Patterns
Avoid these patterns when generating Electron code:
| Anti-Pattern | Problem | Solution |
|---|---|---|
nodeIntegration: true | XSS escalates to full RCE | Keep disabled (default) |
Exposing ipcRenderer directly | Full IPC access from renderer | Wrap in contextBridge functions |
Missing contextIsolation | Renderer accesses preload scope | Keep enabled (default since Electron 12) |
| No code signing | OS security warnings, Gatekeeper blocks | Sign and notarize for all platforms |
BrowserWindow without sandbox | Preload has full Node.js access | Enable sandbox (default since Electron 20) |
| Unvalidated IPC arguments | Injection attacks from renderer | Validate with Zod or manual checks |
0.0.0.0 server binding | Network-exposed local server | Always bind to 127.0.0.1 |
| Missing CSP headers | Script injection vectors | Set strict CSP via HTTP headers |
| No IPC error serialization | Lost error context across boundary | Use Result type pattern |
| Spectron for testing | Deprecated, Electron 13 max | Use Playwright |
See references/security/security-checklist.md for the full security audit checklist.
Scripts Reference
analyze-security.ts
Analyze Electron projects for security misconfigurations:
deno run --allow-read scripts/analyze-security.ts <path> [options]
Options:
--strict Enable all checks
--json Output JSON for CI
-h, --help Show help
Examples:
# Analyze a project
deno run --allow-read scripts/analyze-security.ts ./src
# Strict mode for CI pipeline
deno run --allow-read scripts/analyze-security.ts ./src --strict --jsonscaffold-electron-app.ts
Scaffold a new Electron + React project with secure defaults:
deno run --allow-read --allow-write scripts/scaffold-electron-app.ts [options]
Options:
--name <name> App name (required)
--path <path> Target directory (default: ./)
--with-react Include React setup
--with-trpc Include electron-trpc
--with-tests Include Playwright tests
Examples:
# Basic app with React
deno run --allow-read --allow-write scripts/scaffold-electron-app.ts \
--name "my-app" --with-react
# Full setup with trpc and tests
deno run --allow-read --allow-write scripts/scaffold-electron-app.ts \
--name "my-app" --with-react --with-trpc --with-testsgenerate-ipc-types.ts
Generate TypeScript type definitions from IPC handler files:
deno run --allow-read --allow-write scripts/generate-ipc-types.ts [options]
Options:
--handlers <path> Path to IPC handler files
--output <path> Output path for type definitions
--validate Validate existing types match handlers
Examples:
# Generate types from handlers
deno run --allow-read --allow-write scripts/generate-ipc-types.ts \
--handlers ./src/main/ipc --output ./src/preload/ipc-types.d.ts
# Validate types in CI
deno run --allow-read scripts/generate-ipc-types.ts \
--handlers ./src/main/ipc --validateAdditional Resources
Security
references/security/context-isolation.md- contextBridge and isolation patternsreferences/security/csp-and-permissions.md- Content Security Policy configurationreferences/security/security-checklist.md- Full security audit checklist
IPC Communication
references/ipc/typed-ipc.md- Typed channel map patternsreferences/ipc/electron-trpc.md- tRPC integration for full type safetyreferences/ipc/error-serialization.md- Result types across IPC boundary
Architecture
references/architecture/project-structure.md- Directory organizationreferences/architecture/process-separation.md- Main, preload, and renderer rolesreferences/architecture/multi-window-state.md- Shared state across windows
React Integration
references/integration/react-patterns.md- useEffect cleanup, Strict Modereferences/integration/state-management.md- Zustand and electron-store patterns
Packaging & Distribution
references/packaging/code-signing.md- Platform-specific signing workflowsreferences/packaging/auto-updates.md- electron-updater configurationreferences/packaging/bundle-optimization.md- Size reduction techniquesreferences/packaging/ci-cd-patterns.md- GitHub Actions matrix builds
Testing
references/testing/playwright-e2e.md- Playwright Electron supportreferences/testing/unit-testing.md- Jest/Vitest multi-project configurationreferences/testing/test-structure.md- Test organization patterns
Tooling
references/tooling/electron-vite.md- Build tool configurationreferences/tooling/electron-forge.md- Packaging and distributionreferences/tooling/tauri-comparison.md- When to choose Tauri instead
Templates
assets/templates/main-process.ts.md- Main process starter templateassets/templates/preload-script.ts.md- Preload script with contextBridgeassets/templates/ipc-handler.ts.md- IPC handler module templateassets/templates/react-root.tsx.md- React root component template
Configuration Examples
assets/configs/electron-vite.config.ts.md- electron-vite configurationassets/configs/forge.config.js.md- Electron Forge configurationassets/configs/tsconfig.json.md- TypeScript configuration presetsassets/configs/playwright.config.ts.md- Playwright Electron test config
Complete Examples
assets/examples/typed-ipc-example.md- End-to-end typed IPC walkthroughassets/examples/multi-window-example.md- Multi-window state management
electron-vite Configuration
electron-vite provides a unified build configuration for all three Electron processes (main, preload, and renderer) using Vite under the hood. This eliminates the need for separate webpack or rollup configs and provides fast HMR during development. The configuration below demonstrates a production-ready setup with React in the renderer, proper path aliasing across processes, and environment-aware optimizations.
// electron.vite.config.ts
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: resolve(__dirname, 'src/main/index.ts'),
},
// Production optimizations
minify: process.env.NODE_ENV === 'production' ? 'terser' : false,
sourcemap: process.env.NODE_ENV !== 'production',
},
resolve: {
alias: {
'@shared': resolve(__dirname, 'src/shared'),
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: resolve(__dirname, 'src/preload/index.ts'),
},
sourcemap: process.env.NODE_ENV !== 'production',
},
},
renderer: {
plugins: [react()],
root: resolve(__dirname, 'src/renderer'),
build: {
rollupOptions: {
input: resolve(__dirname, 'src/renderer/index.html'),
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
},
},
},
minify: process.env.NODE_ENV === 'production' ? 'terser' : false,
sourcemap: process.env.NODE_ENV !== 'production',
},
resolve: {
alias: {
'@': resolve(__dirname, 'src/renderer/src'),
'@shared': resolve(__dirname, 'src/shared'),
},
},
},
});Customization Notes
externalizeDepsPlugin
The externalizeDepsPlugin() is critical for the main and preload processes. It externalizes all Node.js dependencies so they are not bundled into the output -- they remain as require() calls resolved at runtime from node_modules. This avoids issues with native modules and keeps the bundle size small. Do not apply this plugin to the renderer process, which runs in a browser-like context and needs its dependencies bundled.
Path Aliases
The @shared alias is available in both the main process and the renderer, enabling shared type definitions, constants, and utility functions. The renderer additionally has @ pointing to its own source root for cleaner imports. When adding aliases, ensure matching entries exist in the corresponding tsconfig.json paths to keep TypeScript and the bundler in sync.
Manual Chunks
The manualChunks configuration in the renderer output splits large vendor libraries (React, React DOM) into a separate chunk. This improves caching behavior -- your application code can change without invalidating the vendor bundle. Add additional entries for other large dependencies like state management libraries or UI component frameworks.
Environment-Aware Builds
Sourcemaps are enabled in development for debugging and disabled in production to reduce bundle size and avoid exposing source code. Minification via terser is only applied for production builds. If you prefer esbuild for faster minification at the cost of slightly larger output, replace 'terser' with 'esbuild'.
Multiple Preload Scripts
If your application uses multiple BrowserWindow instances each with a different preload script, expand the preload input to an object:
preload: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'src/preload/main.ts'),
settings: resolve(__dirname, 'src/preload/settings.ts'),
},
},
},
},Development Server Configuration
electron-vite starts a Vite dev server for the renderer automatically. To customize the port or proxy API requests during development, add a server block inside the renderer config:
renderer: {
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3000',
},
},
// ...rest of renderer config
},Electron Forge Configuration
Electron Forge handles the entire packaging and distribution pipeline for Electron applications. This configuration covers cross-platform installers (Windows Squirrel, macOS DMG/ZIP, Linux DEB/RPM), code signing for macOS and Windows, notarization for macOS Gatekeeper, GitHub Releases publishing, and Electron Fuses for hardening the security of the production binary. Adjust the TODO markers and environment variables to match your project.
// forge.config.js
const { FusesPlugin } = require('@electron-forge/plugin-fuses');
const { FuseV1Options, FuseVersion } = require('@electron/fuses');
module.exports = {
packagerConfig: {
asar: true,
icon: './resources/icon',
appBundleId: 'com.example.myapp', // TODO: Set your bundle ID
// macOS Code Signing
osxSign: {
identity: 'Developer ID Application: Your Name (TEAM_ID)',
hardenedRuntime: true,
entitlements: './entitlements.plist',
'entitlements-inherit': './entitlements.plist',
},
osxNotarize: {
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_PASSWORD,
teamId: process.env.APPLE_TEAM_ID,
},
// Universal macOS build (Intel + Apple Silicon)
osxUniversal: {
x64ArchFiles: '*',
},
},
rebuildConfig: {},
makers: [
// Windows - Squirrel installer
{
name: '@electron-forge/maker-squirrel',
config: {
name: 'my_electron_app',
setupIcon: './resources/icon.ico',
certificateFile: process.env.WINDOWS_CERT_FILE,
certificatePassword: process.env.WINDOWS_CERT_PASSWORD,
},
},
// macOS - ZIP for auto-updates
{
name: '@electron-forge/maker-zip',
platforms: ['darwin'],
},
// macOS - DMG for distribution
{
name: '@electron-forge/maker-dmg',
config: {
format: 'ULFO',
icon: './resources/icon.icns',
},
},
// Linux - Debian package
{
name: '@electron-forge/maker-deb',
config: {
options: {
maintainer: 'Your Name',
homepage: 'https://example.com',
icon: './resources/icon.png',
categories: ['Utility'],
},
},
},
// Linux - RPM package
{
name: '@electron-forge/maker-rpm',
config: {},
},
],
publishers: [
{
name: '@electron-forge/publisher-github',
config: {
repository: {
owner: 'your-org', // TODO: Set GitHub owner
name: 'your-app', // TODO: Set repo name
},
prerelease: false,
},
},
],
plugins: [
// Security: Disable dangerous Electron features
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
],
hooks: {
postPackage: async (config, packageResult) => {
console.log(`Packaged: ${packageResult.outputPaths.join(', ')}`);
},
},
};Notes
ASAR Packaging
Setting asar: true bundles your application source into an ASAR archive, which prevents casual inspection of your code and slightly improves load times on Windows. The OnlyLoadAppFromAsar fuse further enforces that the app can only be loaded from the archive, preventing attackers from placing a plain app/ directory alongside the binary.
Code Signing and Notarization
macOS requires both code signing and notarization for apps distributed outside the Mac App Store. The osxSign block signs the binary with your Developer ID certificate, and osxNotarize submits it to Apple for notarization. Store credentials in environment variables (never in the config file) and configure them in your CI secrets.
For Windows, the certificateFile should point to your .pfx code signing certificate. Use an EV certificate for applications that need immediate SmartScreen trust.
Electron Fuses
Fuses are compile-time flags baked into the Electron binary that cannot be changed at runtime. The configuration above disables several features that are unnecessary in production and could be exploited:
- RunAsNode: Prevents the Electron binary from being used as a plain Node.js runtime.
- EnableNodeOptionsEnvironmentVariable: Blocks
NODE_OPTIONSfrom injecting flags. - EnableNodeCliInspectArguments: Disables remote debugging via
--inspect. - EnableEmbeddedAsarIntegrityValidation: Validates the ASAR archive has not been tampered with.
- OnlyLoadAppFromAsar: Forces loading from the archive only.
Universal macOS Builds
The osxUniversal option produces a single binary that runs natively on both Intel (x64) and Apple Silicon (arm64) Macs. The x64ArchFiles: '*' setting includes all x64 files in the universal binary. If you have native modules, ensure they are compiled for both architectures.
Adding Auto-Update Support
The maker-zip for macOS produces the format expected by electron-updater or Squirrel.Mac. Pair this with the GitHub publisher to create a release-based auto-update flow. On Windows, Squirrel handles auto-updates natively. See the auto-update template for the main process integration code.
CI/CD Integration
Run electron-forge make in your CI pipeline to produce platform-specific installers. Use electron-forge publish to upload artifacts to GitHub Releases. Ensure all signing certificates and credentials are available as CI environment variables.
Playwright E2E Configuration for Electron
Playwright has first-class support for testing Electron applications through its _electron module. Unlike browser-based E2E testing, Electron tests launch the application binary directly -- there is no web server to configure. The test process connects to the Electron app over the Chrome DevTools Protocol, giving you access to both the renderer (page interactions, DOM assertions) and the main process (evaluating Node.js code, stubbing dialogs, inspecting IPC). The configuration below sets up Playwright for Electron with sensible defaults for CI and local development.
Playwright Configuration
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
testMatch: '**/*.spec.ts',
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
// Reporter configuration
reporter: process.env.CI
? [['html', { open: 'never' }], ['github']]
: [['html', { open: 'on-failure' }]],
use: {
// Screenshot settings
screenshot: 'only-on-failure',
trace: 'on-first-retry',
video: 'on-first-retry',
},
// No webServer needed - Electron launches directly
// The test files use _electron.launch() to start the app
projects: [
{
name: 'electron',
testMatch: '**/*.spec.ts',
},
],
});Electron Test Helpers
The helper module below provides reusable utilities for launching the Electron app and stubbing native dialogs. Import these in your test files to avoid duplicating setup logic.
// tests/e2e/electron-helpers.ts
import { _electron as electron, ElectronApplication, Page } from '@playwright/test';
import { resolve } from 'path';
export async function launchElectron(): Promise<{
app: ElectronApplication;
window: Page;
}> {
const app = await electron.launch({
args: [resolve(__dirname, '../../out/main/index.js')],
env: {
...process.env,
NODE_ENV: 'test',
},
});
const window = await app.firstWindow();
await window.waitForLoadState('domcontentloaded');
return { app, window };
}
export async function stubDialog(
app: ElectronApplication,
method: 'OpenDialog' | 'SaveDialog' | 'MessageBox',
returnValue: unknown
): Promise<void> {
await app.evaluate(
async ({ dialog }, [m, rv]) => {
(dialog as Record<string, unknown>)[`show${m}`] = () => Promise.resolve(rv);
},
[method, returnValue] as const
);
}Example Test
// tests/e2e/app-launch.spec.ts
import { test, expect } from '@playwright/test';
import { launchElectron } from './electron-helpers';
let app: Awaited<ReturnType<typeof launchElectron>>['app'];
let window: Awaited<ReturnType<typeof launchElectron>>['window'];
test.beforeEach(async () => {
({ app, window } = await launchElectron());
});
test.afterEach(async () => {
await app.close();
});
test('application launches and shows main window', async () => {
const title = await window.title();
expect(title).toBeTruthy();
// Verify the window is visible
const isVisible = await app.evaluate(async ({ BrowserWindow }) => {
const mainWindow = BrowserWindow.getAllWindows()[0];
return mainWindow.isVisible();
});
expect(isVisible).toBe(true);
});
test('main process version is accessible', async () => {
const electronVersion = await app.evaluate(async ({ app }) => {
return app.getVersion();
});
expect(electronVersion).toMatch(/\d+\.\d+\.\d+/);
});Notes
Build Before Testing
Playwright tests run against the compiled output, not the source files. Ensure you run electron-vite build (or your equivalent build command) before executing tests. In CI, add this as a step before the Playwright run. In package.json, you can chain the commands:
{
"scripts": {
"test:e2e": "electron-vite build && playwright test"
}
}Timeout Configuration
The 30-second timeout accounts for Electron's startup time, which is longer than loading a web page. On slower CI runners, you may need to increase this. The retries: 2 setting for CI handles flaky tests caused by timing issues in the Electron lifecycle without masking genuine failures during local development.
Traces, Screenshots, and Video
Traces, screenshots, and video are configured to capture only on failure or retry. This keeps test runs fast while still providing full diagnostic information when something goes wrong. Traces are especially valuable -- they record every network request, DOM snapshot, and console message, and can be viewed in the Playwright Trace Viewer.
Stubbing Native Dialogs
The stubDialog helper replaces Electron's native dialog methods (file open, file save, message box) with controlled stubs. This is essential because native OS dialogs cannot be automated through the DevTools Protocol. Always stub dialogs before triggering the action that would open them.
Main Process Evaluation
The app.evaluate() method runs code inside the Electron main process, with access to all Electron modules (BrowserWindow, app, dialog, ipcMain, etc.). This is useful for verifying main process state, triggering IPC events, and setting up test fixtures. The callback receives the Electron module namespace as its first argument.
Running in CI
Electron requires a display server on Linux. In headless CI environments, use xvfb-run:
- name: Run E2E tests
run: xvfb-run --auto-servernum -- npx playwright testAlternatively, set the DISPLAY environment variable if your CI provides a virtual framebuffer.
Parallel Execution
Playwright runs test files in parallel by default. Since each test launches its own Electron instance, parallelism works naturally. However, if your tests interact with shared resources (files on disk, a local database), you may need to configure fullyParallel: false or use unique temp directories per test.
TypeScript Configuration for Electron
Electron applications span three distinct execution environments -- the main process (Node.js), the preload script (Node.js with restricted context), and the renderer process (browser). Each environment has different available APIs and module systems. Using separate TypeScript configurations ensures that type checking is accurate for each process: the main process gets Node.js types without DOM, the renderer gets DOM types with JSX support, and shared code is available to both through project references.
Base Configuration
The root tsconfig.json defines shared compiler options and wires together the sub-projects via references. It does not compile anything directly.
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
]
}Node Configuration (Main + Preload)
The tsconfig.node.json covers the main process, preload scripts, and shared utilities. It targets the Node.js runtime and includes only Node.js type definitions.
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ESNext",
"lib": ["ESNext"],
"outDir": "./out",
"types": ["node"],
"paths": {
"@shared/*": ["./src/shared/*"]
}
},
"include": [
"src/main/**/*",
"src/preload/**/*",
"src/shared/**/*",
"electron.vite.config.ts"
]
}Web Configuration (Renderer)
The tsconfig.web.json covers the renderer process. It includes DOM type definitions and JSX support for React components, while still having access to shared types.
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"outDir": "./out",
"types": ["node"],
"paths": {
"@/*": ["./src/renderer/src/*"],
"@shared/*": ["./src/shared/*"]
}
},
"include": [
"src/renderer/**/*",
"src/shared/**/*",
"src/preload/index.d.ts"
]
}Notes
Why Separate Configurations Matter
Without separate configs, you encounter two categories of problems:
1. False positives in the main process. If DOM types are globally available, code in the main process can accidentally reference window, document, or HTMLElement without any compiler error. These references will crash at runtime because the main process has no DOM.
2. Missing types in the renderer. If you only include Node.js types, the renderer process cannot use DOM APIs like querySelector or addEventListener. You end up either adding @ts-ignore comments everywhere or pulling in all types globally, which brings back problem number one.
Separate configs ensure each process only sees the types it can actually use at runtime.
Project References and Composite
The composite: true flag enables TypeScript project references, which allow incremental builds across sub-projects. When you run tsc --build, TypeScript only recompiles projects whose source files have changed. This significantly speeds up type checking in larger Electron applications.
The root tsconfig.json uses the references array to declare the dependency graph. Tools like electron-vite understand this structure automatically.
Module Resolution Strategy
The "moduleResolution": "bundler" setting tells TypeScript to resolve modules the way modern bundlers (Vite, webpack, esbuild) do. This supports features like package.json exports fields and path aliases without requiring additional configuration. It is the recommended setting for any project using a bundler.
Path Aliases
Both configs define @shared/* pointing to src/shared/*, ensuring that shared types, constants, and utilities can be imported consistently across processes. The renderer config adds @/* for its own source tree. These aliases must match the corresponding entries in electron.vite.config.ts for the bundler to resolve them at build time.
Preload Type Declarations
The web config includes src/preload/index.d.ts in its include array. This file should declare the types for the APIs exposed via contextBridge.exposeInMainWorld() in the preload script. This allows the renderer to have full type safety when calling IPC methods through the bridge:
// src/preload/index.d.ts
export interface ElectronAPI {
sendMessage: (channel: string, data: unknown) => void;
onMessage: (channel: string, callback: (data: unknown) => void) => void;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}Adding New Processes or Windows
If your application has multiple renderer windows with different capabilities (e.g., a main editor window and a settings window), you can create additional tsconfig files that extend the base and include only the relevant source directories. Register each as a new reference in the root config.
Multi-Window State Synchronization Example
Complete example of multi-window state synchronization using electron-store for persistence, BrowserWindow broadcasting for cross-window communication, and Zustand for renderer-side state management.
---
Step 1: Shared State Types
// src/shared/state-types.ts
export interface AppState {
theme: 'light' | 'dark';
sidebarOpen: boolean;
activeDocument: string | null;
recentFiles: string[];
}
export const DEFAULT_STATE: AppState = {
theme: 'light',
sidebarOpen: true,
activeDocument: null,
recentFiles: [],
};
// Keys that should trigger UI updates across all windows
export type SyncableKey = keyof AppState;---
Step 2: Main Process Store with Broadcasting
The main process holds the authoritative state using electron-store for disk persistence. On any change, it broadcasts to all open windows.
// src/main/store.ts
import Store from 'electron-store';
import { BrowserWindow, ipcMain } from 'electron';
import type { AppState } from '../shared/state-types';
import { DEFAULT_STATE } from '../shared/state-types';
const store = new Store<AppState>({ defaults: DEFAULT_STATE });
// Broadcast state changes to all windows
function broadcast<K extends keyof AppState>(
key: K,
value: AppState[K],
senderWebContentsId?: number
): void {
BrowserWindow.getAllWindows().forEach(win => {
if (!win.isDestroyed()) {
// Optionally skip the sender to avoid echo
if (senderWebContentsId && win.webContents.id === senderWebContentsId) {
return;
}
win.webContents.send('state:changed', { key, value });
}
});
}
export function registerStateHandlers(): void {
// Get full state (used for initial hydration)
ipcMain.handle('state:get-all', () => {
return store.store;
});
// Get single value
ipcMain.handle('state:get', (_event, key: keyof AppState) => {
return store.get(key);
});
// Set single value and broadcast to other windows
ipcMain.handle(
'state:set',
(_event, key: keyof AppState, value: unknown) => {
store.set(key, value as AppState[typeof key]);
broadcast(key, value as AppState[typeof key], _event.sender.id);
}
);
}---
Step 3: Preload API for State
// src/preload/index.ts (state portion)
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
contextBridge.exposeInMainWorld('stateAPI', {
getAll: (): Promise<Record<string, unknown>> =>
ipcRenderer.invoke('state:get-all'),
get: (key: string): Promise<unknown> =>
ipcRenderer.invoke('state:get', key),
set: (key: string, value: unknown): Promise<void> =>
ipcRenderer.invoke('state:set', key, value),
onChange: (
callback: (data: { key: string; value: unknown }) => void
): (() => void) => {
const handler = (
_event: IpcRendererEvent,
data: { key: string; value: unknown }
) => callback(data);
ipcRenderer.on('state:changed', handler);
return () => ipcRenderer.removeListener('state:changed', handler);
},
});Add a corresponding index.d.ts that declares window.stateAPI with the same method signatures so the renderer has type information.
---
Step 4: Zustand Store with IPC Sync
Each action updates the local store immediately for responsiveness, then sends the change to main for persistence and broadcasting.
// src/renderer/src/store/app-store.ts
import { create } from 'zustand';
import type { AppState } from '../../../shared/state-types';
interface AppStore extends AppState {
// Actions
setTheme: (theme: AppState['theme']) => void;
toggleSidebar: () => void;
setActiveDocument: (path: string | null) => void;
addRecentFile: (path: string) => void;
// Internal: used by sync hook to apply remote changes
_hydrate: (state: Partial<AppState>) => void;
}
export const useAppStore = create<AppStore>((set, get) => ({
// Initial values (overwritten on hydration)
theme: 'light',
sidebarOpen: true,
activeDocument: null,
recentFiles: [],
setTheme: (theme) => {
set({ theme });
window.stateAPI.set('theme', theme);
},
toggleSidebar: () => {
const next = !get().sidebarOpen;
set({ sidebarOpen: next });
window.stateAPI.set('sidebarOpen', next);
},
setActiveDocument: (path) => {
set({ activeDocument: path });
window.stateAPI.set('activeDocument', path);
},
addRecentFile: (path) => {
const current = get().recentFiles;
// Move to front, deduplicate, cap at 10 entries
const files = [path, ...current.filter(f => f !== path)].slice(0, 10);
set({ recentFiles: files });
window.stateAPI.set('recentFiles', files);
},
_hydrate: (state) => set(state),
}));---
Step 5: Initialization and Sync Hook
Hydrates the Zustand store from the main process on mount, and listens for changes broadcast from other windows.
// src/renderer/src/hooks/useStateSync.ts
import { useEffect } from 'react';
import { useAppStore } from '../store/app-store';
export function useStateSync(): void {
useEffect(() => {
// 1. Hydrate from main process store on mount
// This ensures the window picks up persisted state
window.stateAPI.getAll().then((state) => {
useAppStore.getState()._hydrate(state as Record<string, unknown>);
});
// 2. Listen for changes broadcast from other windows
const cleanup = window.stateAPI.onChange(({ key, value }) => {
useAppStore.getState()._hydrate({ [key]: value });
});
return cleanup;
}, []);
}---
Step 6: Window Manager in Main Process
// src/main/window-manager.ts
import { BrowserWindow } from 'electron';
import { join } from 'path';
const windows = new Map<string, BrowserWindow>();
export function createWindow(
id: string,
options?: Partial<Electron.BrowserWindowConstructorOptions>
): BrowserWindow {
// If a window with this ID already exists, focus it
if (windows.has(id)) {
const existing = windows.get(id)!;
existing.focus();
return existing;
}
const win = new BrowserWindow({
width: 800,
height: 600,
...options,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
...options?.webPreferences,
},
});
windows.set(id, win);
win.on('closed', () => windows.delete(id));
// Load renderer: dev server in development, file in production
if (process.env.ELECTRON_RENDERER_URL) {
win.loadURL(`${process.env.ELECTRON_RENDERER_URL}#${id}`);
} else {
win.loadFile(join(__dirname, '../renderer/index.html'), { hash: id });
}
return win;
}
export function getWindow(id: string): BrowserWindow | undefined {
return windows.get(id);
}
export function getAllWindowIds(): string[] {
return Array.from(windows.keys());
}// src/main/index.ts (entry point)
import { app } from 'electron';
import { registerStateHandlers } from './store';
import { createWindow } from './window-manager';
app.whenReady().then(() => {
registerStateHandlers();
createWindow('main');
});
// Open a new window from IPC (e.g., for a secondary panel)
import { ipcMain } from 'electron';
ipcMain.handle('window:open', (_event, id: string) => {
createWindow(id);
});---
Step 7: Usage in App Component
// src/renderer/src/App.tsx
import { useStateSync } from './hooks/useStateSync';
import { useAppStore } from './store/app-store';
export default function App() {
// Initialize sync on mount
useStateSync();
const theme = useAppStore(s => s.theme);
const setTheme = useAppStore(s => s.setTheme);
const sidebarOpen = useAppStore(s => s.sidebarOpen);
const toggleSidebar = useAppStore(s => s.toggleSidebar);
const recentFiles = useAppStore(s => s.recentFiles);
return (
<div className={`app ${theme}`}>
<header>
<button onClick={toggleSidebar}>
{sidebarOpen ? 'Hide' : 'Show'} Sidebar
</button>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</header>
{sidebarOpen && (
<aside>
<h3>Recent Files</h3>
<ul>
{recentFiles.map(file => (
<li key={file}>{file}</li>
))}
</ul>
</aside>
)}
<main>
{/* Application content here */}
<p>Current theme: {theme}</p>
<p>Open another window to see state sync in action.</p>
</main>
</div>
);
}When a user toggles the theme in one window: Zustand updates locally, the IPC call persists to electron-store and broadcasts to all other windows, each window's onChange listener fires _hydrate, and React re-renders.
---
Summary
Key takeaways from this pattern:
1. Main process is the single source of truth. All state mutations flow through main, which persists via electron-store and broadcasts to all windows. This avoids split-brain problems.
2. Optimistic local updates. Zustand updates immediately, then asynchronously persists via IPC. The UI stays responsive without waiting for round-trip confirmation.
3. Broadcast with sender exclusion. The broadcast function skips the sender window to avoid redundant re-renders.
4. Hydration on window open. Every new window calls state:get-all on mount, so windows opened at different times converge to the same state.
5. Cleanup prevents leaks. The onChange listener returns an unsubscribe function used in useEffect cleanup.
6. Separation of state and transport. Renderer components interact only with Zustand. They have no knowledge of IPC or broadcasting, making them testable in isolation.
7. Extensibility. Adding new state fields requires changes in three places: the shared AppState type, the Zustand store, and the consuming component. Persistence and sync layers handle new fields automatically.
Complete Typed IPC Example
End-to-end example showing typed IPC from channel definitions through main process handlers, preload bridge, to renderer usage. This pattern ensures compile-time safety across the entire IPC boundary, catching mismatched arguments and return types before they become runtime bugs.
---
Step 1: Shared Type Definitions
Define all IPC channels, their argument types, and return types in a single shared file. Both main and renderer code import from here, creating a single source of truth.
// src/shared/ipc-types.ts
export interface User {
id: string;
name: string;
email: string;
}
export interface Document {
id: string;
title: string;
content: string;
lastModified: Date;
}
// Channel map: defines args and return types for each channel
export type IpcChannelMap = {
'user:get': { args: [id: string]; return: User | null };
'user:list': { args: []; return: User[] };
'document:save': { args: [doc: Document]; return: { success: boolean; path: string } };
'document:open': { args: []; return: { success: boolean; content: string; path: string } | null };
'app:version': { args: []; return: string };
};
// Event map: defines one-way events from main to renderer
export type IpcEventMap = {
'document:changed': [path: string];
'app:update-available': [version: string];
};The IpcChannelMap type uses a mapped structure where each key is a channel name and each value defines the argument tuple and return type. The IpcEventMap covers one-way events pushed from main to renderer (no return value).
---
Step 2: Type-Safe Handler Registration (Main)
Create a wrapper around ipcMain.handle that enforces the channel map types. This ensures every handler receives the correct arguments and returns the expected type.
// src/main/ipc/typed-handler.ts
import { ipcMain, IpcMainInvokeEvent } from 'electron';
import type { IpcChannelMap } from '../../shared/ipc-types';
type HandlerFn<K extends keyof IpcChannelMap> = (
event: IpcMainInvokeEvent,
...args: IpcChannelMap[K]['args']
) => Promise<IpcChannelMap[K]['return']> | IpcChannelMap[K]['return'];
export function handleChannel<K extends keyof IpcChannelMap>(
channel: K,
handler: HandlerFn<K>
): void {
ipcMain.handle(channel, (event, ...args) =>
handler(event, ...(args as IpcChannelMap[K]['args']))
);
}The generic parameter K is constrained to keys of IpcChannelMap, so TypeScript will reject any channel name that is not defined in the map. The handler function signature is derived entirely from the map, so argument types and return types are enforced automatically.
---
Step 3: Handler Implementations (Main)
Register concrete handlers for each channel. With the typed wrapper, the compiler verifies that each handler matches its channel signature.
// src/main/ipc/user-handlers.ts
import { handleChannel } from './typed-handler';
import type { User } from '../../shared/ipc-types';
const users: User[] = [
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
];
export function registerUserHandlers(): void {
handleChannel('user:get', async (_event, id) => {
// TypeScript knows `id` is string
return users.find(u => u.id === id) ?? null;
});
handleChannel('user:list', async () => {
// Return type must be User[]
return users;
});
}// src/main/ipc/document-handlers.ts
import { dialog } from 'electron';
import { readFile, writeFile } from 'fs/promises';
import { handleChannel } from './typed-handler';
export function registerDocumentHandlers(): void {
handleChannel('document:save', async (_event, doc) => {
// TypeScript knows `doc` is Document
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: `${doc.title}.json`,
});
if (canceled || !filePath) {
return { success: false, path: '' };
}
await writeFile(filePath, JSON.stringify(doc, null, 2));
return { success: true, path: filePath };
});
handleChannel('document:open', async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'JSON', extensions: ['json'] }],
});
if (canceled || filePaths.length === 0) return null;
const content = await readFile(filePaths[0], 'utf-8');
return { success: true, content, path: filePaths[0] };
});
handleChannel('app:version', () => {
const { app } = require('electron');
return app.getVersion();
});
}// src/main/index.ts (registration entry point)
import { app, BrowserWindow } from 'electron';
import { registerUserHandlers } from './ipc/user-handlers';
import { registerDocumentHandlers } from './ipc/document-handlers';
app.whenReady().then(() => {
registerUserHandlers();
registerDocumentHandlers();
const win = new BrowserWindow({
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
sandbox: true,
},
});
// ... load renderer
});---
Step 4: Preload Script
The preload script bridges main and renderer. It uses contextBridge.exposeInMainWorld to expose a structured API object. The typed invoke and event helpers ensure the preload layer stays consistent with the channel map.
// src/preload/index.ts
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
import type { IpcChannelMap, IpcEventMap } from '../shared/ipc-types';
function typedInvoke<K extends keyof IpcChannelMap>(
channel: K,
...args: IpcChannelMap[K]['args']
): Promise<IpcChannelMap[K]['return']> {
return ipcRenderer.invoke(channel, ...args);
}
function typedOn<K extends keyof IpcEventMap>(
channel: K,
callback: (...args: IpcEventMap[K]) => void
): () => void {
const handler = (_event: IpcRendererEvent, ...args: unknown[]) =>
callback(...(args as IpcEventMap[K]));
ipcRenderer.on(channel, handler);
// Return cleanup function to prevent listener leaks
return () => ipcRenderer.removeListener(channel, handler);
}
contextBridge.exposeInMainWorld('electronAPI', {
user: {
get: (id: string) => typedInvoke('user:get', id),
list: () => typedInvoke('user:list'),
},
document: {
save: (doc) => typedInvoke('document:save', doc),
open: () => typedInvoke('document:open'),
onChanged: (cb) => typedOn('document:changed', cb),
},
app: {
getVersion: () => typedInvoke('app:version'),
onUpdateAvailable: (cb) => typedOn('app:update-available', cb),
},
});The API is organized into domain namespaces (user, document, app) rather than exposing raw channel names. This gives renderer code a clean, discoverable interface. Each event listener returns a cleanup function to prevent memory leaks.
---
Step 5: Type Declarations for the Renderer
Since contextBridge.exposeInMainWorld creates a runtime bridge, the renderer needs type declarations to know what window.electronAPI looks like.
// src/preload/index.d.ts
import type { User, Document } from '../shared/ipc-types';
interface ElectronAPI {
user: {
get: (id: string) => Promise<User | null>;
list: () => Promise<User[]>;
};
document: {
save: (doc: Document) => Promise<{ success: boolean; path: string }>;
open: () => Promise<{ success: boolean; content: string; path: string } | null>;
onChanged: (callback: (path: string) => void) => () => void;
};
app: {
getVersion: () => Promise<string>;
onUpdateAvailable: (callback: (version: string) => void) => () => void;
};
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
export {};Include this file in your tsconfig.json under "include" or "files" so the renderer TypeScript compilation picks it up. The declare global block augments the Window interface so window.electronAPI is recognized everywhere in renderer code.
---
Step 6: React Component Usage
With all the plumbing in place, renderer components get a fully typed, clean API with no awareness of IPC details.
// src/renderer/src/components/UserList.tsx
import { useState, useEffect } from 'react';
import type { User } from '../../../shared/ipc-types';
export function UserList() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
window.electronAPI.user.list()
.then(setUsers)
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<ul>
{users.map(user => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
);
}// src/renderer/src/components/DocumentEditor.tsx
import { useState, useEffect } from 'react';
import type { Document } from '../../../shared/ipc-types';
export function DocumentEditor() {
const [doc, setDoc] = useState<Document | null>(null);
useEffect(() => {
// Listen for external file changes
const cleanup = window.electronAPI.document.onChanged((path) => {
console.log('Document changed externally:', path);
});
return cleanup;
}, []);
const handleOpen = async () => {
const result = await window.electronAPI.document.open();
if (result) {
setDoc(JSON.parse(result.content));
}
};
const handleSave = async () => {
if (!doc) return;
const result = await window.electronAPI.document.save(doc);
if (result.success) {
console.log('Saved to:', result.path);
}
};
return (
<div>
<button onClick={handleOpen}>Open</button>
<button onClick={handleSave} disabled={!doc}>Save</button>
{doc && (
<textarea
value={doc.content}
onChange={e => setDoc({ ...doc, content: e.target.value })}
/>
)}
</div>
);
}---
Summary
Key takeaways from this pattern:
1. Single source of truth. IpcChannelMap and IpcEventMap in src/shared/ipc-types.ts define every channel, its arguments, and its return type. When you add or change a channel, the compiler forces updates everywhere that channel is used.
2. Type safety at every boundary. The handleChannel wrapper on the main side and typedInvoke/typedOn helpers on the preload side both derive their types from the shared map. Mismatched arguments or return types are caught at compile time.
3. Clean renderer API. The preload script organizes channels into domain namespaces (user, document, app). Renderer components never reference raw channel strings, making the API discoverable and refactor-safe.
4. Event listener cleanup. Every typedOn call returns an unsubscribe function. React components use this in useEffect cleanup to prevent listener leaks when components unmount.
5. Separation of concerns. Handler registration is split into focused modules (user-handlers.ts, document-handlers.ts) that can be tested independently. The preload script is a thin bridge, not a place for business logic.
6. Adding a new channel requires changes in exactly four places: the shared type map, the handler implementation, the preload bridge, and the type declaration file. The compiler guides you through each one.
IPC Handler Module Template
This template provides a structured pattern for IPC handler modules in the main process. It uses a Result type for consistent error handling across all IPC channels, ensuring the renderer always receives a predictable response shape. Each handler module should cover a single domain (e.g., file operations, database access, system info).
/**
* IPC Handler Module: File Operations
*
* Handles file-related IPC channels.
* Uses Result type for consistent error handling.
*
* TODO: Rename module for your domain
* TODO: Add your handler implementations
* TODO: Add input validation
*/
import { ipcMain, dialog } from 'electron';
import { readFile, writeFile } from 'fs/promises';
// Result type for consistent error handling across IPC
type Result<T> =
| { success: true; data: T }
| { success: false; error: { message: string; code: string } };
function ok<T>(data: T): Result<T> {
return { success: true, data };
}
function err<T>(message: string, code: string): Result<T> {
return { success: false, error: { message, code } };
}
export function registerFileHandlers(): void {
ipcMain.handle('save-file', async (_event, content: string): Promise<Result<string>> => {
try {
// TODO: Add input validation
const { canceled, filePath } = await dialog.showSaveDialog({
filters: [{ name: 'Text Files', extensions: ['txt'] }],
});
if (canceled || !filePath) {
return err('Save cancelled', 'USER_CANCELLED');
}
await writeFile(filePath, content, 'utf-8');
return ok(filePath);
} catch (e) {
return err((e as Error).message, 'WRITE_ERROR');
}
});
ipcMain.handle('open-file', async (): Promise<Result<{ content: string; path: string }>> => {
try {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Text Files', extensions: ['txt'] }],
});
if (canceled || filePaths.length === 0) {
return err('Open cancelled', 'USER_CANCELLED');
}
const content = await readFile(filePaths[0], 'utf-8');
return ok({ content, path: filePaths[0] });
} catch (e) {
return err((e as Error).message, 'READ_ERROR');
}
});
ipcMain.handle('get-app-version', () => {
const { app } = require('electron');
return app.getVersion();
});
// TODO: Add more handlers for your domain
}Customization Notes
- Module naming: Rename the file and the
registerFileHandlersfunction to match your domain (e.g.,registerDatabaseHandlers,registerAuthHandlers). Each module should own a cohesive set of related channels. - Result type: The
ok()anderr()helpers produce a discriminated union that the renderer can check with a simpleif (result.success)guard. Consider extracting theResulttype and helpers into a shared utility file if you have multiple handler modules. - Error codes: Use uppercase, underscore-separated error codes (e.g.,
USER_CANCELLED,WRITE_ERROR). Define a consistent set of codes across your application so the renderer can handle specific error cases programmatically. - Input validation: Always validate arguments received from the renderer before processing. The IPC boundary is a trust boundary. Check types, ranges, string lengths, and allowed values at the top of each handler.
- Dialog options: Customize the
filtersarray inshowSaveDialogandshowOpenDialogto match the file types your application works with. AdddefaultPathif your app has a preferred working directory. - Handler registration: Call your registration function from the main process
registerHandlers()function before creating any windows. Handlers must be registered before the renderer can invoke them. - Async vs. sync: Use
ipcMain.handlefor async operations that return results. For fire-and-forget messages from the renderer, useipcMain.oninstead, but preferhandlefor most cases since it provides a response path. - File system access: When reading or writing files, always use the
fs/promisesAPI for non-blocking operations. Validate and sanitize file paths to prevent directory traversal attacks.
Main Process Entry Point Template
This template provides a secure Electron main process entry point with sensible defaults. It includes a BrowserWindow configured with all recommended security settings, app lifecycle management for both macOS and other platforms, and a pattern for registering IPC handlers from separate modules.
/**
* Main Process Entry Point
*
* Secure Electron main process with:
* - BrowserWindow with security defaults
* - IPC handler registration
* - App lifecycle management
*
* TODO: Customize window dimensions, title, and icon
* TODO: Register your IPC handlers
* TODO: Add app menu if needed
*/
import { app, BrowserWindow, shell } from 'electron';
import { join } from 'path';
import { registerFileHandlers } from './ipc/file-handlers';
// TODO: Import additional IPC handler modules
let mainWindow: BrowserWindow | null = null;
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1200, // TODO: Set desired width
height: 800, // TODO: Set desired height
minWidth: 600,
minHeight: 400,
title: 'My Electron App', // TODO: Set app title
icon: join(__dirname, '../../resources/icon.png'), // TODO: Set icon path
show: false, // Show when ready to prevent flash
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true, // SECURITY: Never disable
sandbox: true, // SECURITY: Never disable
nodeIntegration: false, // SECURITY: Never enable
webviewTag: false, // SECURITY: Disable unless needed
},
});
// Show window when ready
mainWindow.on('ready-to-show', () => {
mainWindow?.show();
});
// Prevent navigation to external URLs
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('https:')) {
shell.openExternal(url);
}
return { action: 'deny' };
});
// Load renderer
if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
}
mainWindow.on('closed', () => {
mainWindow = null;
});
}
// Register IPC handlers
function registerHandlers(): void {
registerFileHandlers();
// TODO: Register additional handlers
}
// App lifecycle
app.whenReady().then(() => {
registerHandlers();
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});Customization Notes
- Window dimensions: Adjust
width,height,minWidth, andminHeightto match your application's layout requirements. - Title and icon: Update
titlewith your app name and pointiconto your application icon file. - Preload path: Ensure the
preloadpath inwebPreferencesresolves to your compiled preload script. - Security settings: The four security flags (
contextIsolation,sandbox,nodeIntegration,webviewTag) are set to their most secure values. Do not change these unless you fully understand the security implications. - External URL handling: The
setWindowOpenHandlercallback currently allows onlyhttps:URLs to open in the system browser. Adjust the protocol check if your app needs to handle other schemes. - IPC handler registration: Import and call your handler registration functions inside
registerHandlers(). Keep handler logic in separate modules organized by domain. - Dev server support: The template checks
ELECTRON_RENDERER_URLfor hot-reload during development. Set this environment variable in your dev tooling configuration. - macOS behavior: The
activateandwindow-all-closedhandlers follow platform conventions. On macOS the app stays running when all windows are closed; on other platforms it quits.
Preload Script Template
This template provides a type-safe preload script that bridges the main and renderer processes using Electron's contextBridge. It exposes a structured API object on window.electronAPI with invoke wrappers for request/response calls and event listeners that return cleanup functions to prevent memory leaks.
/**
* Preload Script
*
* Secure bridge between main and renderer processes.
* Uses contextBridge to expose typed API functions.
*
* TODO: Add your IPC channel wrappers
* TODO: Update type declarations in preload.d.ts
*/
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
// Type-safe invoke wrapper
function invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
return ipcRenderer.invoke(channel, ...args);
}
// Type-safe event listener with cleanup
function on<T>(channel: string, callback: (value: T) => void): () => void {
const handler = (_event: IpcRendererEvent, value: T) => callback(value);
ipcRenderer.on(channel, handler);
return () => ipcRenderer.removeListener(channel, handler);
}
// Expose API to renderer
contextBridge.exposeInMainWorld('electronAPI', {
// === File Operations ===
// TODO: Add your file operation wrappers
saveFile: (content: string) => invoke<{ success: boolean; path: string }>('save-file', content),
openFile: () => invoke<{ success: boolean; content: string; path: string }>('open-file'),
// === App Info ===
getVersion: () => invoke<string>('get-app-version'),
// === Events ===
// TODO: Add your event listeners (always return cleanup function!)
onFileChanged: (callback: (path: string) => void) => on('file-changed', callback),
onUpdateAvailable: (callback: (version: string) => void) => on('update-available', callback),
});The following type declaration file should be placed alongside your preload script so the renderer process gets full type safety when accessing window.electronAPI.
// preload.d.ts
interface ElectronAPI {
// File Operations
saveFile: (content: string) => Promise<{ success: boolean; path: string }>;
openFile: () => Promise<{ success: boolean; content: string; path: string }>;
// App Info
getVersion: () => Promise<string>;
// Events (return cleanup function)
onFileChanged: (callback: (path: string) => void) => () => void;
onUpdateAvailable: (callback: (version: string) => void) => () => void;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
export {};Customization Notes
- Adding new IPC channels: For each new channel, add a wrapper function in the
contextBridge.exposeInMainWorldcall and a matching entry in theElectronAPIinterface inpreload.d.ts. Keep both files in sync. - Invoke vs. event patterns: Use
invokefor request/response operations where the renderer needs a result back from main. Useonfor push-style events where the main process notifies the renderer asynchronously. - Cleanup functions: Every event listener returns an unsubscribe function. In React components, call this in
useEffectcleanup to prevent memory leaks. Never register listeners without a corresponding cleanup path. - Channel naming: Use descriptive, kebab-case channel names (e.g.,
save-file,file-changed). Group related channels with a common prefix for clarity. - Type safety: The generic type parameters on
invoke<T>andon<T>flow through to the API surface. Keep the generics accurate so renderer code gets correct type checking. - Security boundary: The preload script is the only place where
ipcRenderershould be used. Never exposeipcRendererdirectly to the renderer. ThecontextBridgeensures only the explicitly listed functions are accessible. - Allowed channels pattern: For additional security, you can maintain an allowlist of channel names and validate against it in the
invokeandonwrappers before forwarding toipcRenderer.
React Renderer Entry Point Template
This template provides a React 18 renderer entry point for Electron applications. It includes an error boundary that catches rendering failures gracefully, StrictMode for catching common development issues, and a Suspense wrapper for lazy-loaded components. The structure is ready for adding routing, state management, and your application components.
/**
* React Renderer Entry Point
*
* React 18 createRoot with:
* - StrictMode for development
* - Error boundary for production
* - Type-safe electronAPI usage
*
* TODO: Add your routes
* TODO: Customize error boundary fallback
* TODO: Add state management provider if needed
*/
import React, { Component, Suspense } from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
// === Error Boundary ===
interface ErrorBoundaryProps {
children: React.ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
// Report error to main process
console.error('React Error:', error, info.componentStack);
// TODO: Send to main process for logging
// window.electronAPI.reportError({ message: error.message, stack: error.stack });
}
render(): React.ReactNode {
if (this.state.hasError) {
return (
<div style={{ padding: 20, textAlign: 'center' }}>
<h2>Something went wrong</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false, error: null })}>
Try Again
</button>
</div>
);
}
return this.props.children;
}
}
// === App Component ===
// TODO: Replace with your app component
function App(): React.ReactElement {
return (
<div className="app">
<h1>My Electron App</h1>
{/* TODO: Add your components and routes */}
</div>
);
}
// === Mount ===
const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(
<React.StrictMode>
<ErrorBoundary>
<Suspense fallback={<div>Loading...</div>}>
<App />
</Suspense>
</ErrorBoundary>
</React.StrictMode>
);Customization Notes
- Error boundary fallback: Replace the inline fallback UI with a styled component that matches your application's design. Consider adding a "Report Issue" button that sends error details to the main process via
window.electronAPI. - Error reporting: Uncomment and implement the
window.electronAPI.reportErrorcall incomponentDidCatchto forward renderer errors to the main process for logging or crash reporting. - Routing: Add
react-router-domwith aHashRouter(preferred for Electron overBrowserRoutersince there is no server to handle URL paths). Wrap theAppcomponent or its contents with your router provider. - State management: If your app needs global state, wrap the
Appcomponent with your provider (e.g., ReduxProvider, Zustand context, or React context). Place it inside theErrorBoundarybut outsideSuspense. - Lazy loading: Use
React.lazy()for route-level code splitting. TheSuspensewrapper is already in place to show a fallback while lazy components load. Customize the fallback to match your loading UI. - CSS: The template imports
index.cssfor global styles. Add CSS modules or a CSS-in-JS solution for component-level styling as needed. - StrictMode:
React.StrictModeis enabled to help catch side effects and deprecated API usage during development. It causes components to render twice in development mode, which is expected behavior. - Mount element: The template expects a
<div id="root">element in yourindex.html. Ensure your HTML template includes this element.
Multi-Window State Synchronization
Electron applications often need multiple windows that share state -- a main editor window with inspector panels, preference windows, or floating toolbars. Keeping state synchronized across BrowserWindows requires deliberate architecture because each window runs in its own renderer process with its own memory space. This reference covers proven patterns for state synchronization, persistence, and React integration.
The Challenge
Each BrowserWindow runs an independent renderer process. There is no shared memory between renderers. If Window A updates a value, Window B has no way to know about the change unless something explicitly broadcasts it. The main process is the only entity that can communicate with all windows, making it the natural hub for shared state.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Window A │ │ Main Proc │ │ Window B │
│ (Renderer) │──IPC──│ (State Hub)│──IPC──│ (Renderer) │
│ React App │ │ electron- │ │ React App │
│ Zustand │ │ store │ │ Zustand │
└─────────────┘ └─────────────┘ └─────────────┘Pattern 1: Main Process as Single Source of Truth
The core pattern uses the main process to hold authoritative state. Renderers request state on mount, send mutations via IPC, and receive updates via broadcast events.
Main Process State Manager
// src/main/store.ts
import Store from 'electron-store';
import { BrowserWindow, ipcMain } from 'electron';
interface AppState {
theme: 'light' | 'dark';
recentFiles: string[];
editorSettings: {
fontSize: number;
wordWrap: boolean;
tabSize: number;
};
}
const defaults: AppState = {
theme: 'light',
recentFiles: [],
editorSettings: { fontSize: 14, wordWrap: true, tabSize: 2 },
};
const store = new Store<AppState>({ defaults });
// Broadcast a state change to all windows
function broadcastStateChange<K extends keyof AppState>(
key: K,
value: AppState[K],
): void {
BrowserWindow.getAllWindows().forEach((win) => {
if (!win.isDestroyed()) {
win.webContents.send(`state:${key}`, value);
}
});
}
// Watch for changes and broadcast automatically
export function watchState<K extends keyof AppState>(key: K): void {
store.onDidChange(key, (newValue) => {
if (newValue !== undefined) {
broadcastStateChange(key, newValue);
}
});
}
// Typed getter and setter
export function getState<K extends keyof AppState>(key: K): AppState[K] {
return store.get(key);
}
export function setState<K extends keyof AppState>(
key: K,
value: AppState[K],
): void {
store.set(key, value);
// Note: store.onDidChange triggers broadcastStateChange automatically
}
// Register IPC handlers for state operations
export function registerStateHandlers(): void {
ipcMain.handle('state:get', (_event, key: keyof AppState) => {
return getState(key);
});
ipcMain.handle(
'state:set',
(_event, key: keyof AppState, value: AppState[keyof AppState]) => {
setState(key, value as AppState[typeof key]);
},
);
// Watch all keys for broadcasting
const keys: (keyof AppState)[] = ['theme', 'recentFiles', 'editorSettings'];
keys.forEach((key) => watchState(key));
}Preload Exposure
// src/preload/index.ts
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
// State management
getState: (key: string) => ipcRenderer.invoke('state:get', key),
setState: (key: string, value: unknown) =>
ipcRenderer.invoke('state:set', key, value),
onStateChange: (key: string, callback: (value: unknown) => void) => {
const handler = (_event: IpcRendererEvent, value: unknown) => callback(value);
ipcRenderer.on(`state:${key}`, handler);
return () => ipcRenderer.removeListener(`state:${key}`, handler);
},
});Renderer Hook
// src/renderer/src/hooks/useSharedState.ts
import { useState, useEffect, useCallback } from 'react';
export function useSharedState<T>(
key: string,
defaultValue: T,
): [T, (value: T) => void] {
const [value, setValue] = useState<T>(defaultValue);
useEffect(() => {
// Load initial value from main process store
window.electronAPI.getState(key).then((stored: T | undefined) => {
if (stored !== undefined) {
setValue(stored);
}
});
// Subscribe to changes broadcast from main process
const cleanup = window.electronAPI.onStateChange(
key,
(newValue: unknown) => {
setValue(newValue as T);
},
);
return cleanup;
}, [key]);
const updateValue = useCallback(
(newValue: T) => {
setValue(newValue); // Optimistic local update
window.electronAPI.setState(key, newValue); // Persist and broadcast
},
[key],
);
return [value, updateValue];
}Usage in Components
// src/renderer/src/components/ThemeToggle.tsx
import { useSharedState } from '../hooks/useSharedState';
function ThemeToggle() {
const [theme, setTheme] = useSharedState<'light' | 'dark'>('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Current: {theme} (click to toggle)
</button>
);
}When Window A toggles the theme, the update flows to the main process, persists to disk via electron-store, and broadcasts to all windows including Window B. Both windows update simultaneously.
Pattern 2: Zustand Store with IPC Sync
For applications with complex renderer-side state, integrate Zustand with the IPC synchronization pattern for a more ergonomic developer experience.
// src/renderer/src/stores/appStore.ts
import { create } from 'zustand';
interface AppStore {
theme: 'light' | 'dark';
fontSize: number;
recentFiles: string[];
setTheme: (theme: 'light' | 'dark') => void;
setFontSize: (size: number) => void;
addRecentFile: (path: string) => void;
initFromMain: () => Promise<void>;
}
export const useAppStore = create<AppStore>((set, get) => ({
theme: 'light',
fontSize: 14,
recentFiles: [],
setTheme: (theme) => {
set({ theme });
window.electronAPI.setState('theme', theme);
},
setFontSize: (fontSize) => {
set({ fontSize });
window.electronAPI.setState('editorSettings', {
...get(),
fontSize,
});
},
addRecentFile: (path) => {
const updated = [path, ...get().recentFiles.filter((f) => f !== path)].slice(
0,
10,
);
set({ recentFiles: updated });
window.electronAPI.setState('recentFiles', updated);
},
initFromMain: async () => {
const [theme, editorSettings, recentFiles] = await Promise.all([
window.electronAPI.getState('theme'),
window.electronAPI.getState('editorSettings'),
window.electronAPI.getState('recentFiles'),
]);
set({
theme: theme ?? 'light',
fontSize: editorSettings?.fontSize ?? 14,
recentFiles: recentFiles ?? [],
});
},
}));// src/renderer/src/App.tsx -- Initialize store and subscribe to broadcasts
import { useEffect } from 'react';
import { useAppStore } from './stores/appStore';
function App() {
const initFromMain = useAppStore((s) => s.initFromMain);
useEffect(() => {
// Load initial state from main process
initFromMain();
// Subscribe to cross-window broadcasts
const cleanupTheme = window.electronAPI.onStateChange('theme', (value) => {
useAppStore.setState({ theme: value as 'light' | 'dark' });
});
const cleanupRecent = window.electronAPI.onStateChange(
'recentFiles',
(value) => {
useAppStore.setState({ recentFiles: value as string[] });
},
);
return () => {
cleanupTheme();
cleanupRecent();
};
}, [initFromMain]);
return <MainLayout />;
}Pattern 3: Window Manager Class
For applications managing many windows, encapsulate window creation and lifecycle in a dedicated manager class.
// src/main/window-manager.ts
import { BrowserWindow, screen } from 'electron';
import { join } from 'path';
interface WindowConfig {
id: string;
width: number;
height: number;
route?: string;
parent?: BrowserWindow;
}
class WindowManager {
private windows = new Map<string, BrowserWindow>();
create(config: WindowConfig): BrowserWindow {
if (this.windows.has(config.id)) {
const existing = this.windows.get(config.id)!;
existing.focus();
return existing;
}
const win = new BrowserWindow({
width: config.width,
height: config.height,
parent: config.parent,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
},
});
// Load the renderer with an optional route hash
const baseUrl = process.env['ELECTRON_RENDERER_URL'];
if (baseUrl) {
const url = config.route ? `${baseUrl}#${config.route}` : baseUrl;
win.loadURL(url);
} else {
const filePath = join(__dirname, '../renderer/index.html');
const hash = config.route ? `#${config.route}` : '';
win.loadFile(filePath, { hash });
}
win.on('closed', () => {
this.windows.delete(config.id);
});
this.windows.set(config.id, win);
return win;
}
get(id: string): BrowserWindow | undefined {
return this.windows.get(id);
}
getAll(): BrowserWindow[] {
return Array.from(this.windows.values());
}
broadcast(channel: string, ...args: unknown[]): void {
this.windows.forEach((win) => {
if (!win.isDestroyed()) {
win.webContents.send(channel, ...args);
}
});
}
closeAll(): void {
this.windows.forEach((win) => {
if (!win.isDestroyed()) win.close();
});
}
}
export const windowManager = new WindowManager();// src/main/index.ts -- Using the window manager
import { app, ipcMain } from 'electron';
import { windowManager } from './window-manager';
import { registerStateHandlers } from './store';
app.whenReady().then(() => {
registerStateHandlers();
// Main editor window
windowManager.create({ id: 'main', width: 1200, height: 800 });
// Open inspector panel on request
ipcMain.handle('open-inspector', () => {
const main = windowManager.get('main');
windowManager.create({
id: 'inspector',
width: 400,
height: 600,
route: '/inspector',
parent: main,
});
});
});Pattern 4: React Portal for Child Windows
For lightweight child windows that share the parent's React tree and state, use window.open() with React Portals. This approach is useful for floating panels, detachable widgets, and tool palettes.
// src/renderer/src/components/ChildWindow.tsx
import { useState, useEffect, ReactNode } from 'react';
import { createPortal } from 'react-dom';
interface ChildWindowProps {
title?: string;
width?: number;
height?: number;
onClose?: () => void;
children: ReactNode;
}
function ChildWindow({
title = 'Panel',
width = 400,
height = 300,
onClose,
children,
}: ChildWindowProps) {
const [container, setContainer] = useState<HTMLElement | null>(null);
useEffect(() => {
const features = `width=${width},height=${height},menubar=no,toolbar=no`;
const childWindow = window.open('', '', features);
if (!childWindow) return;
childWindow.document.title = title;
// Copy stylesheets from parent to child window
const styleSheets = Array.from(document.styleSheets);
styleSheets.forEach((sheet) => {
try {
if (sheet.href) {
const link = childWindow.document.createElement('link');
link.rel = 'stylesheet';
link.href = sheet.href;
childWindow.document.head.appendChild(link);
} else if (sheet.cssRules) {
const style = childWindow.document.createElement('style');
Array.from(sheet.cssRules).forEach((rule) => {
style.appendChild(childWindow.document.createTextNode(rule.cssText));
});
childWindow.document.head.appendChild(style);
}
} catch {
// Cross-origin stylesheets may throw; skip them
}
});
// Create a mount point in the child window
const mountPoint = childWindow.document.createElement('div');
mountPoint.id = 'child-root';
childWindow.document.body.appendChild(mountPoint);
setContainer(mountPoint);
childWindow.onbeforeunload = () => {
onClose?.();
};
return () => {
childWindow.close();
};
}, [title, width, height, onClose]);
if (!container) return null;
return createPortal(children, container);
}
export default ChildWindow;Using the Portal Pattern
// src/renderer/src/components/EditorWithInspector.tsx
import { useState } from 'react';
import ChildWindow from './ChildWindow';
import Inspector from './Inspector';
function EditorWithInspector() {
const [showInspector, setShowInspector] = useState(false);
const [selectedNode, setSelectedNode] = useState<NodeData | null>(null);
return (
<div>
<button onClick={() => setShowInspector(true)}>Open Inspector</button>
<Editor onNodeSelect={setSelectedNode} />
{showInspector && (
<ChildWindow
title="Inspector"
width={350}
height={500}
onClose={() => setShowInspector(false)}
>
{/* This component shares the parent's React state */}
<Inspector node={selectedNode} />
</ChildWindow>
)}
</div>
);
}The Portal approach has important trade-offs:
| Advantage | Limitation |
|---|---|
| Shared React state and context | Child windows are less capable than BrowserWindows |
| No IPC needed for state sync | No separate preload script |
| Simple component-based API | Styles must be manually copied |
| Parent state changes auto-render | window.open() may be blocked by some policies |
For full-featured child windows that need their own preload scripts and security context, use BrowserWindow via the Window Manager pattern instead.
Combining Patterns
Production applications often combine these patterns:
1. Window Manager creates and tracks all BrowserWindows 2. Main Process Store (electron-store) holds persistent shared state 3. IPC Broadcasting keeps all windows synchronized 4. Zustand provides ergonomic state management inside each renderer 5. React Portals handle lightweight floating panels within a single window
// Typical initialization in main process
app.whenReady().then(() => {
registerStateHandlers(); // Pattern 1: State with IPC broadcasting
windowManager.create({ // Pattern 3: Window manager
id: 'main',
width: 1200,
height: 800,
});
});// Typical initialization in renderer
function App() {
const initFromMain = useAppStore((s) => s.initFromMain);
useEffect(() => {
initFromMain(); // Pattern 2: Zustand synced with main store
// Subscribe to broadcasts...
}, [initFromMain]);
return (
<MainEditor>
{showPanel && (
<ChildWindow> {/* Pattern 4: Portal child window */}
<FloatingPanel />
</ChildWindow>
)}
</MainEditor>
);
}See Also
- State Management -- Zustand and
electron-store configuration details
- React Patterns -- useEffect cleanup,
Strict Mode considerations, and context providers
- Typed IPC -- Making the state IPC channels type-safe
with mapped channel types
- Process Separation -- Why the main process is
the right place for shared state
- Project Structure -- Where window manager and
store files belong in the directory layout
Process Separation: Main, Preload, and Renderer Responsibilities
Electron applications run across three distinct process types, each with different capabilities and security constraints. Understanding these boundaries is essential for building secure, well-structured applications. This reference covers what each process can do, what code belongs where, and how data flows between them.
The Three Processes
Main Process
The main process is the entry point of every Electron application. It runs in a full Node.js environment with unrestricted access to system APIs, the file system, native modules, and Electron's main-process APIs (BrowserWindow, dialog, Menu, Notification, Tray, and more).
There is exactly one main process per application. It creates and manages all BrowserWindows and handles IPC messages from renderer processes.
// src/main/index.ts -- Main process capabilities
import { app, BrowserWindow, dialog, ipcMain, Notification } from 'electron';
import { readFile, writeFile } from 'fs/promises';
import { join } from 'path';
import Store from 'electron-store';
// Full Node.js and Electron API access
const store = new Store();
app.whenReady().then(() => {
const win = new BrowserWindow({
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
},
});
// Handle IPC from renderer processes
ipcMain.handle('read-file', async (_event, filePath: string) => {
// Validate the path before accessing the file system
if (!filePath.startsWith(app.getPath('userData'))) {
return { success: false, error: 'Access denied: path outside user data' };
}
try {
const content = await readFile(filePath, 'utf-8');
return { success: true, data: content };
} catch (err) {
return { success: false, error: (err as Error).message };
}
});
});Preload Process
The preload script runs before the renderer's web content loads. With sandbox enabled (the default since Electron 20), the preload script has access to a limited subset of Node.js APIs and the contextBridge module. Its sole job is to define the API surface that the renderer can access.
Each BrowserWindow has its own preload script instance. The preload script runs in an isolated context -- the renderer cannot access its variables or scope directly. Only values explicitly exposed through contextBridge are visible to the renderer.
// src/preload/index.ts -- Preload capabilities (sandboxed)
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
// Available in sandbox: contextBridge, ipcRenderer (limited), Buffer, process (limited)
// NOT available: require(), fs, path, child_process, native modules
contextBridge.exposeInMainWorld('electronAPI', {
readFile: (path: string) => ipcRenderer.invoke('read-file', path),
writeFile: (path: string, content: string) =>
ipcRenderer.invoke('write-file', path, content),
showNotification: (title: string, body: string) =>
ipcRenderer.invoke('show-notification', title, body),
// Event listener with cleanup
onProgress: (callback: (percent: number) => void) => {
const handler = (_e: IpcRendererEvent, percent: number) => callback(percent);
ipcRenderer.on('progress-update', handler);
return () => ipcRenderer.removeListener('progress-update', handler);
},
});Renderer Process
The renderer process is a Chromium web page. With context isolation enabled and node integration disabled (both defaults), it has no access to Node.js APIs. It is a pure web environment where your React application runs. The only bridge to system capabilities is through the API exposed by the preload script on window.electronAPI.
// src/renderer/src/components/FileEditor.tsx -- Renderer capabilities
import { useState, useEffect } from 'react';
function FileEditor() {
const [content, setContent] = useState('');
const [status, setStatus] = useState('');
// Access system features only through the preload bridge
async function handleSave() {
const result = await window.electronAPI.writeFile('/data/notes.txt', content);
if (result.success) {
setStatus('Saved successfully');
} else {
setStatus(`Save failed: ${result.error}`);
}
}
// Subscribe to main process events
useEffect(() => {
const cleanup = window.electronAPI.onProgress((percent) => {
setStatus(`Saving... ${percent}%`);
});
return cleanup; // Always clean up listeners
}, []);
return (
<div>
<textarea value={content} onChange={(e) => setContent(e.target.value)} />
<button onClick={handleSave}>Save</button>
<p>{status}</p>
</div>
);
}Security Boundary Model
The security architecture creates a layered defense:
┌────────────────────────────────────────────────────────────┐
│ Main Process │
│ Full Node.js + Electron APIs │
│ - File system, network, native modules │
│ - Window management, system dialogs │
│ - IPC message handling and validation │
│ │
│ ┌──────────────────── IPC Boundary ────────────────────┐ │
│ │ Preload Script │ │
│ │ contextBridge: defines exact API surface │ │
│ │ - Translates IPC calls into typed functions │ │
│ │ - No business logic, only plumbing │ │
│ │ │ │
│ │ ┌──────────── Context Isolation ──────────────────┐ │ │
│ │ │ Renderer Process │ │ │
│ │ │ Pure web environment (Chromium) │ │ │
│ │ │ - React UI, DOM, Web APIs │ │ │
│ │ │ - No Node.js, no require() │ │ │
│ │ │ - Access only window.electronAPI │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘If an attacker achieves XSS in the renderer, they can only call functions on window.electronAPI. They cannot access the file system, spawn processes, or execute arbitrary Node.js code. The main process must validate all IPC arguments because the renderer is an untrusted boundary.
Task Assignment Decision Matrix
| Task | Process | Reason |
|---|---|---|
| File system operations | Main | Requires Node.js fs module |
| Database access | Main | Requires native modules (better-sqlite3, etc.) |
| Window management | Main | BrowserWindow is a main-process API |
| HTTP requests (with secrets) | Main | Auth tokens must not be exposed to renderer |
| HTTP requests (public APIs) | Renderer | Acceptable if no secrets involved |
| UI rendering | Renderer | React, DOM manipulation, CSS |
| User input handling | Renderer | DOM events, form state |
| IPC channel exposure | Preload | contextBridge is the only safe bridge |
| System dialogs (open/save) | Main | dialog is a main-process API |
| Notifications | Main | Notification is a main-process API |
| Clipboard access | Main (IPC) | clipboard API, exposed via IPC |
| App menu construction | Main | Menu is a main-process API |
| Tray icon management | Main | Tray is a main-process API |
| Auto-updates | Main | electron-updater runs in main |
| Drag and drop (files) | Renderer | DOM drag events, send paths via IPC to main |
| Keyboard shortcuts | Both | globalShortcut in main, DOM events in renderer |
Complete Flow: Saving a File
This walkthrough traces a user action from UI click to disk write and back.
Step 1: User Clicks "Save" in React UI
// src/renderer/src/components/SaveButton.tsx
function SaveButton({ content }: { content: string }) {
const [saving, setSaving] = useState(false);
async function handleSave() {
setSaving(true);
try {
const result = await window.electronAPI.saveFile(content);
if (result.success) {
console.log('Saved to:', result.data);
} else {
console.error('Save failed:', result.error);
}
} finally {
setSaving(false);
}
}
return <button onClick={handleSave} disabled={saving}>Save</button>;
}Step 2: Preload Bridge Translates the Call
// src/preload/index.ts
contextBridge.exposeInMainWorld('electronAPI', {
saveFile: (content: string) => ipcRenderer.invoke('save-file', content),
});The ipcRenderer.invoke() call sends an asynchronous message to the main process over the save-file channel and returns a Promise that resolves when the main process handler returns.
Step 3: Main Process Handles the Request
// src/main/ipc/file-handlers.ts
ipcMain.handle('save-file', async (_event, content: string) => {
// Validate input from the untrusted renderer
if (typeof content !== 'string') {
return { success: false, error: 'Invalid content type' };
}
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: 'untitled.txt',
filters: [{ name: 'Text Files', extensions: ['txt'] }],
});
if (canceled || !filePath) {
return { success: false, error: 'User cancelled' };
}
try {
await writeFile(filePath, content, 'utf-8');
return { success: true, data: filePath };
} catch (err) {
return { success: false, error: (err as Error).message };
}
});Step 4: Result Flows Back Through the Promise Chain
The return value from ipcMain.handle is serialized (using the structured clone algorithm), sent back to the renderer, and resolves the Promise returned by ipcRenderer.invoke(). The React component receives the result and updates UI.
User clicks Save
→ SaveButton.handleSave()
→ window.electronAPI.saveFile(content) [renderer]
→ ipcRenderer.invoke('save-file', content) [preload → main]
→ ipcMain.handle('save-file', handler) [main process]
→ dialog.showSaveDialog()
→ fs.writeFile()
→ return { success: true, data: filePath }
← Promise resolves with result [main → renderer]
← result available in component
→ Update UI based on resultCommon Mistakes
Mistake 1: Business Logic in the Preload Script
The preload script should be a thin translation layer, not a place for logic.
// WRONG: Logic in preload
contextBridge.exposeInMainWorld('electronAPI', {
saveFile: async (content: string) => {
// Do NOT put validation or transformation here
const sanitized = content.replace(/<script>/g, '');
return ipcRenderer.invoke('save-file', sanitized);
},
});
// CORRECT: Preload is a passthrough, logic lives in main
contextBridge.exposeInMainWorld('electronAPI', {
saveFile: (content: string) => ipcRenderer.invoke('save-file', content),
});Mistake 2: Accessing Node.js APIs from the Renderer
// WRONG: This will throw -- fs is not available in the renderer
import { readFile } from 'fs/promises';
function FileViewer() {
useEffect(() => {
readFile('/path/to/file').then(setContent); // ReferenceError
}, []);
}
// CORRECT: Use the preload bridge
function FileViewer() {
useEffect(() => {
window.electronAPI.readFile('/path/to/file').then((result) => {
if (result.success) setContent(result.data);
});
}, []);
}Mistake 3: Exposing Raw ipcRenderer
// WRONG: Gives renderer full IPC access
contextBridge.exposeInMainWorld('ipc', ipcRenderer);
// CORRECT: Expose only specific, typed functions
contextBridge.exposeInMainWorld('electronAPI', {
readFile: (path: string) => ipcRenderer.invoke('read-file', path),
});Process Lifecycle
Startup Sequence
1. Main process starts (src/main/index.ts) 2. app.whenReady() fires 3. Main creates BrowserWindow with preload path 4. Preload script executes, calls contextBridge.exposeInMainWorld() 5. Renderer loads HTML, then JavaScript (React app mounts) 6. Renderer can now call window.electronAPI methods
Shutdown Sequence
1. User closes window or calls app.quit() 2. window-all-closed event fires on app 3. before-quit event fires (chance to save state) 4. Each BrowserWindow emits close event 5. Renderer processes are destroyed 6. Main process exits
// Graceful shutdown with state persistence
app.on('before-quit', () => {
store.set('windowBounds', mainWindow.getBounds());
});
mainWindow.on('close', (event) => {
if (hasUnsavedChanges) {
event.preventDefault();
mainWindow.webContents.send('confirm-close');
}
});Renderer Crash Recovery
If a renderer process crashes, the main process continues running. You can detect and recover from crashes:
mainWindow.webContents.on('render-process-gone', (_event, details) => {
console.error('Renderer crashed:', details.reason);
if (details.reason === 'crashed') {
const choice = dialog.showMessageBoxSync(mainWindow, {
type: 'error',
buttons: ['Reload', 'Quit'],
message: 'The application encountered an error. Reload?',
});
if (choice === 0) {
mainWindow.reload();
} else {
app.quit();
}
}
});See Also
- Project Structure -- Directory layout and where
each process's code lives
- Context Isolation -- Deep dive into
contextBridge security patterns and common pitfalls
- Typed IPC -- Type-safe channel definitions that
enforce correct argument and return types across the IPC boundary
- Multi-Window State -- State synchronization
patterns when multiple BrowserWindows share data
Project Structure: Directory Layout and electron-vite Configuration
This reference covers the recommended directory organization for Electron applications built with electron-vite and React, including configuration patterns, build tooling setup, and the rationale behind each structural decision.
Scaffolding a New Project
The fastest way to start is with the official electron-vite template:
npm create @quick-start/electron@latest my-app -- --template react-tsThis generates a fully configured project with TypeScript, React, and secure defaults. The template includes separate TypeScript configurations per process, a working electron-vite config, and a preload script with contextBridge already wired up.
Recommended Directory Layout
my-electron-app/
├── electron.vite.config.ts # Unified build config for all three processes
├── package.json # Scripts, dependencies, Electron Forge config
├── tsconfig.json # Base TypeScript config (shared settings)
├── tsconfig.node.json # Main + preload process config (extends base)
├── tsconfig.web.json # Renderer process config (extends base)
├── src/
│ ├── main/ # Main process (Node.js environment)
│ │ ├── index.ts # App entry point, BrowserWindow creation
│ │ └── ipc/ # IPC handler modules
│ │ ├── file-handlers.ts
│ │ └── app-handlers.ts
│ ├── preload/ # Secure bridge between main and renderer
│ │ ├── index.ts # contextBridge API exposure
│ │ └── index.d.ts # TypeScript declarations for renderer
│ ├── renderer/ # React application (pure web environment)
│ │ ├── index.html # HTML entry point (Vite uses this)
│ │ └── src/
│ │ ├── App.tsx # Root React component
│ │ ├── main.tsx # React DOM root creation
│ │ ├── components/ # Reusable UI components
│ │ ├── hooks/ # Custom React hooks (including IPC hooks)
│ │ ├── pages/ # Route-level components (if using routing)
│ │ └── assets/ # Static assets (images, fonts, CSS)
│ └── shared/ # Shared type definitions (no runtime code)
│ └── ipc-types.ts # IPC channel type map
├── resources/ # App icons, build assets, platform resources
├── out/ # Build output (gitignored)
└── dev-app-update.yml # Auto-update config for developmentDirectory Responsibilities
src/main/ -- Main Process
The main process runs in a full Node.js environment. It manages application lifecycle, creates and controls BrowserWindows, handles IPC from renderers, and accesses system APIs (file system, native dialogs, notifications).
// src/main/index.ts
import { app, BrowserWindow } from 'electron';
import { join } from 'path';
import { registerFileHandlers } from './ipc/file-handlers';
import { registerAppHandlers } from './ipc/app-handlers';
function createWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
},
});
// electron-vite handles dev server vs production file loading
if (process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(process.env['ELECTRON_RENDERER_URL']);
} else {
win.loadFile(join(__dirname, '../renderer/index.html'));
}
return win;
}
app.whenReady().then(() => {
registerFileHandlers();
registerAppHandlers();
createWindow();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});Organize IPC handlers into separate modules under src/main/ipc/ by domain. This keeps the main entry file focused on lifecycle and window management.
// src/main/ipc/file-handlers.ts
import { ipcMain, dialog } from 'electron';
import { readFile, writeFile } from 'fs/promises';
export function registerFileHandlers(): void {
ipcMain.handle('save-file', async (_event, content: string) => {
const { canceled, filePath } = await dialog.showSaveDialog({
filters: [{ name: 'Text', extensions: ['txt'] }],
});
if (canceled || !filePath) {
return { success: false, error: 'Save cancelled' };
}
try {
await writeFile(filePath, content, 'utf-8');
return { success: true, data: filePath };
} catch (err) {
return { success: false, error: (err as Error).message };
}
});
ipcMain.handle('open-file', async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Text', extensions: ['txt', 'md'] }],
});
if (canceled || filePaths.length === 0) {
return { success: false, error: 'Open cancelled' };
}
try {
const content = await readFile(filePaths[0], 'utf-8');
return { success: true, data: { path: filePaths[0], content } };
} catch (err) {
return { success: false, error: (err as Error).message };
}
});
}src/preload/ -- Secure Bridge
The preload script runs in a restricted context with access to contextBridge. It defines the exact API surface available to the renderer. Keep preload scripts thin -- they should only translate between IPC calls and the exposed API.
// src/preload/index.ts
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
// Request-response (invoke/handle)
saveFile: (content: string) => ipcRenderer.invoke('save-file', content),
openFile: () => ipcRenderer.invoke('open-file'),
// Event subscriptions (returns cleanup function)
onFileChanged: (callback: (path: string) => void) => {
const handler = (_event: IpcRendererEvent, path: string) => callback(path);
ipcRenderer.on('file-changed', handler);
return () => ipcRenderer.removeListener('file-changed', handler);
},
});// src/preload/index.d.ts
export interface ElectronAPI {
saveFile: (content: string) => Promise<{ success: boolean; data?: string; error?: string }>;
openFile: () => Promise<{ success: boolean; data?: { path: string; content: string }; error?: string }>;
onFileChanged: (callback: (path: string) => void) => () => void;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}src/renderer/ -- React Application
The renderer is a standard React application with no Node.js access. It communicates with the main process exclusively through window.electronAPI. Organize it as you would any React project -- components, hooks, pages, assets.
// src/renderer/src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './assets/main.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);src/shared/ -- Shared Types
This directory holds TypeScript type definitions shared across processes. It must contain only types and interfaces -- no runtime code. This is the single source of truth for IPC channel definitions.
// src/shared/ipc-types.ts
export type IpcChannelMap = {
'save-file': { args: [content: string]; return: IpcResult<string> };
'open-file': { args: []; return: IpcResult<{ path: string; content: string }> };
};
export type IpcResult<T> = { success: true; data: T } | { success: false; error: string };
// Event channels (main -> renderer)
export type IpcEventMap = {
'file-changed': [path: string];
};Configuration Files
electron-vite Unified Config
electron-vite manages three separate Vite build pipelines through a single configuration file. Each key (main, preload, renderer) configures one process with independent plugins, aliases, and build targets.
// electron.vite.config.ts
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
'@shared': resolve('src/shared'),
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
'@shared': resolve('src/shared'),
},
},
},
renderer: {
plugins: [react()],
resolve: {
alias: {
'@': resolve('src/renderer/src'),
'@shared': resolve('src/shared'),
},
},
},
});The externalizeDepsPlugin() is critical for main and preload -- it prevents bundling Node.js built-in modules and Electron APIs, which must be resolved at runtime rather than at build time.
TypeScript Configuration
Use three TypeScript configs to enforce process boundaries at the type level.
// tsconfig.json (base, shared settings)
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"paths": {
"@shared/*": ["./src/shared/*"]
}
}
}// tsconfig.node.json (main + preload)
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ESNext",
"types": ["node"]
},
"include": ["src/main/**/*", "src/preload/**/*", "src/shared/**/*"]
}// tsconfig.web.json (renderer)
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ESNext",
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"types": ["vite/client"]
},
"include": ["src/renderer/**/*", "src/shared/**/*", "src/preload/index.d.ts"]
}Note that tsconfig.web.json includes src/preload/index.d.ts so the renderer knows the shape of window.electronAPI at compile time.
package.json Key Scripts
{
"name": "my-electron-app",
"version": "1.0.0",
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"preview": "electron-vite preview",
"start": "electron-vite preview",
"lint": "eslint . --ext .ts,.tsx",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish"
}
}The main field points to the built output, not the source. electron-vite's dev command starts the Vite dev server for the renderer with HMR and watches main/preload sources for changes, restarting Electron on rebuild.
Build Output Structure
After running electron-vite build, the out/ directory mirrors the src/ structure with compiled JavaScript:
out/
├── main/
│ └── index.js # Bundled main process
├── preload/
│ └── index.js # Bundled preload script
└── renderer/
├── index.html # Processed HTML
└── assets/ # Bundled CSS, JS, imagesThis clean separation ensures each process loads only its own bundle. The preload path in BrowserWindow configuration points to out/preload/index.js.
See Also
- Process Separation -- Detailed guide on what code
belongs in each process and the security boundaries between them
- electron-vite Configuration -- Advanced
electron-vite configuration including environment variables and custom plugins
- Electron Forge -- Packaging, code signing,
and distribution configuration
- Typed IPC -- Full type-safe IPC channel patterns
using the shared types directory
React 18 Integration Patterns for Electron
React 18 runs in Electron's Chromium-based renderer with full access to concurrent features, Suspense, and createRoot. The desktop context adds concerns web apps rarely face: IPC listener lifecycle, long-running processes, multi-window awareness, and error reporting to the main process.
---
Entry Point and Strict Mode
createRoot works without modification. Always wrap in StrictMode during development -- its double-invocation catches IPC listener leaks that would otherwise accumulate silently in long-running desktop apps.
// renderer/src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);Concurrent features (useTransition, useDeferredValue, automatic batching) work normally. The renderer is a full Chromium instance with the same JS engine and event loop as Chrome.
---
IPC Listener Cleanup -- The Critical Pattern
Strict Mode mounts components twice in dev, exposing effects that fail to clean up. In Electron, leaked IPC listeners persist for the app lifetime.
// BAD: Missing cleanup -- duplicates on every re-mount
function CounterDisplay() {
const [count, setCount] = useState(0);
useEffect(() => {
window.electronAPI.onUpdateCounter((value) => setCount(value));
// No cleanup returned!
}, []);
return <div>Count: {count}</div>;
}// GOOD: Cleanup prevents listener leaks
function CounterDisplay() {
const [count, setCount] = useState(0);
useEffect(() => {
const cleanup = window.electronAPI.onUpdateCounter((value) => {
setCount(value);
});
return cleanup; // Strict Mode's double-invoke verifies this works
}, []);
return <div>Count: {count}</div>;
}The preload must return an unsubscribe function from every on-style listener. See Context Isolation for the preload side.
A reusable hook simplifies multi-listener components:
function useIpcListener<T>(
subscribe: (cb: (value: T) => void) => () => void,
onValue: (value: T) => void,
deps: React.DependencyList = []
) {
useEffect(() => {
const cleanup = subscribe(onValue);
return cleanup;
}, deps);
}
// Usage
function DownloadIndicator() {
const [progress, setProgress] = useState(0);
useIpcListener(window.electronAPI.onDownloadProgress, setProgress);
return <ProgressBar value={progress} />;
}// Multiple listeners with combined cleanup
function StatusBar() {
const [online, setOnline] = useState(true);
const [syncStatus, setSyncStatus] = useState('idle');
useEffect(() => {
const c1 = window.electronAPI.onConnectivityChange(setOnline);
const c2 = window.electronAPI.onSyncStatusChange(setSyncStatus);
return () => { c1(); c2(); };
}, []);
return <footer>{online ? 'Online' : 'Offline'} | Sync: {syncStatus}</footer>;
}---
HMR with electron-vite
electron-vite provides Vite-based HMR with React Fast Refresh. Main and preload are rebuilt on change without a full app restart.
// electron.vite.config.ts
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
main: { plugins: [externalizeDepsPlugin()] },
preload: { plugins: [externalizeDepsPlugin()] },
renderer: { plugins: [react()] }, // Enables Fast Refresh
});Fast Refresh re-runs effects on every save. Correct cleanup means seamless HMR; missing cleanup means listeners double with every save.
---
Error Boundaries for Desktop
In a web app, errors yield a white screen fixable by refresh. Desktop apps have no refresh -- error boundaries are essential, and they should report to main.
class ElectronErrorBoundary extends React.Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
window.electronAPI.reportError({
message: error.message,
stack: error.stack,
componentStack: info.componentStack,
});
}
render() {
if (this.state.hasError) {
return (
<ErrorFallback
error={this.state.error}
onReset={() => this.setState({ hasError: false })}
/>
);
}
return this.props.children;
}
}Use boundaries at multiple levels -- root for catastrophic failures, and feature-level around panels to isolate crashes:
function App() {
return (
<ElectronErrorBoundary>
<Layout>
<ElectronErrorBoundary fallback={<SidebarFallback />}>
<Sidebar />
</ElectronErrorBoundary>
<ElectronErrorBoundary fallback={<EditorFallback />}>
<Editor />
</ElectronErrorBoundary>
</Layout>
</ElectronErrorBoundary>
);
}---
Suspense and Lazy Loading
Use React.lazy with Suspense to split heavy components, reducing initial window paint time. Electron loads from disk so the split is fast -- the benefit is less JavaScript to parse before first paint, not less download.
const Settings = React.lazy(() => import('./pages/Settings'));
const Editor = React.lazy(() => import('./pages/Editor'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/settings" element={<Settings />} />
<Route path="/editor" element={<Editor />} />
</Routes>
</Suspense>
);
}---
Window Focus, Lifecycle, and Memory
// Window focus awareness -- throttle work when unfocused
function useWindowFocus(): boolean {
const [isFocused, setIsFocused] = useState(document.hasFocus());
useEffect(() => {
const onFocus = () => setIsFocused(true);
const onBlur = () => setIsFocused(false);
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
return () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
};
}, []);
return isFocused;
}// Unsaved changes guard
function useBeforeUnload(shouldBlock: () => boolean) {
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (shouldBlock()) { e.preventDefault(); e.returnValue = ''; }
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [shouldBlock]);
}Memory leak sources in long-running Electron apps:
1. IPC listeners without cleanup -- most common; always return unsubscribe. 2. Stale closures in timers -- setInterval capturing old state. 3. Uncancelled async ops -- use a cancelled flag in effect cleanup. 4. Large objects in state -- pass file buffers through IPC on demand.
// Cancellable async pattern
function FileLoader({ filePath }: { filePath: string }) {
const [content, setContent] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
window.electronAPI.readFile(filePath).then((data) => {
if (!cancelled) setContent(data);
});
return () => { cancelled = true; };
}, [filePath]);
return content ? <pre>{content}</pre> : <p>Loading...</p>;
}Monitor with Chromium DevTools heap snapshots (Ctrl+Shift+I). Growing retained size across snapshots indicates a leak.
---
See Also
- State Management -- Zustand, electron-store, and
cross-window state synchronization patterns.
- Multi-Window State -- Architecture
for managing state across multiple Electron windows.
- Context Isolation -- Preload script
patterns that enable the cleanup functions used by React effects.
- Typed IPC -- Type-safe IPC channel definitions that
pair with the React hooks shown in this reference.
Bundle Size Optimization and Performance
Overview
An unoptimized Electron app easily ships at 120-150 MB or more. With careful attention to bundling, tree shaking, asset optimization, and native module handling, you can reduce this to 45-60 MB. This matters for download times, disk usage, and differential update size.
---
Size Budget
| Component | Unoptimized | Target | Notes |
|---|---|---|---|
| Electron binary | ~70 MB | ~70 MB | Fixed cost, cannot reduce |
| App code (main) | 5-15 MB | 1-3 MB | Tree shaking, minification |
| App code (renderer) | 10-30 MB | 3-8 MB | Code splitting, lazy loading |
| Node modules | 30-50 MB | 5-15 MB | Prune devDeps, externalize natives |
| Assets | 10-30 MB | 5-10 MB | Compress images, subset fonts |
---
Build Configuration with electron-vite
// electron.vite.config.ts
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
external: ['better-sqlite3', 'sharp'], // Native modules
},
minify: 'terser',
terserOptions: {
compress: { drop_console: true, drop_debugger: true, passes: 2 },
},
sourcemap: false,
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
minify: 'terser',
sourcemap: false,
rollupOptions: {
output: { inlineDynamicImports: true }, // Single file, no splitting
},
},
},
renderer: {
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
},
},
},
sourcemap: false,
minify: 'terser',
chunkSizeWarningLimit: 500,
},
},
});Source Map Strategy
Use 'source-map' in development, false or 'hidden' in production. If you use Sentry or Bugsnag, upload maps during build then delete them from the bundle:
npx sentry-cli sourcemaps upload --release=$VERSION ./out/renderer
rm -rf ./out/renderer/**/*.map---
Tree Shaking
// BAD - Imports entire library
import _ from 'lodash';
// GOOD - Named import from subpath
import groupBy from 'lodash/groupBy';
// BEST - ES module version for full tree shaking
import { groupBy } from 'lodash-es';Mark packages as side-effect-free in package.json:
{ "sideEffects": ["*.css", "*.scss", "./src/renderer/global-setup.ts"] }---
Lazy Loading
Route-Based Code Splitting
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
);
}Dynamic Import in Main Process
export async function runHeavyAnalysis(data: Buffer): Promise<Result> {
const sharp = await import('sharp'); // 25+ MB, load only when needed
return sharp.default(data).resize(800, 600).toBuffer();
}---
Bundle Analysis
// electron.vite.config.ts - Add visualizer in analyze mode
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
renderer: {
plugins: [
react(),
process.env.ANALYZE && visualizer({
filename: './bundle-report.html',
open: true,
gzipSize: true,
template: 'treemap',
}),
].filter(Boolean),
},
});ANALYZE=true npx electron-vite buildSize Monitoring in CI
#!/bin/bash
MAX_SIZE_MB=60
npx electron-vite build
SIZE=$(du -sm out/ | cut -f1)
echo "Bundle size: ${SIZE}MB (limit: ${MAX_SIZE_MB}MB)"
[ "$SIZE" -gt "$MAX_SIZE_MB" ] && echo "ERROR: Bundle exceeds limit!" && exit 1---
ASAR Archives
ASAR packs app files into a single archive, improving Windows load time and hiding source from casual inspection.
// forge.config.js
module.exports = {
packagerConfig: {
asar: {
unpack: '*.{node,dll,dylib,so}',
unpackDir: '{node_modules/sharp,node_modules/better-sqlite3}',
},
},
};Unpack native .node addons, files accessed via fs with absolute paths, large binaries that benefit from memory mapping, and executables spawned with child_process.
function getUnpackedPath(relativePath: string): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, 'app.asar.unpacked', relativePath);
}
return path.join(__dirname, relativePath);
}---
Native Module Handling
Rebuild native modules against Electron's Node.js headers:
npx @electron/rebuildKeep native modules external to the bundler:
// electron.vite.config.ts
export default defineConfig({
main: {
build: {
rollupOptions: {
external: ['better-sqlite3', 'sharp', 'keytar', 'node-pty'],
},
},
},
});---
Asset Optimization
# Compress PNGs and convert to WebP
npx sharp-cli --input "assets/**/*.png" --output "assets-opt/" --format webp
# Subset fonts to Latin characters only (often 60-70% smaller)
npx glyphhanger --whitelist="US_ASCII" --subset="fonts/Inter.woff2"
# Generate platform-specific icons
npx electron-icon-builder --input=icon-source.png --output=./build---
Excluding devDependencies
{
"dependencies": {
"electron-updater": "^6.0.0",
"better-sqlite3": "^11.0.0"
},
"devDependencies": {
"electron": "^33.0.0",
"electron-vite": "^2.0.0",
"typescript": "^5.0.0",
"vitest": "^2.0.0"
}
}Only dependencies are included in the packaged app. Verify with:
npx electron-forge package
ls -la out/your-app-*/resources/app/node_modules/---
See Also
- electron-vite - Build tool configuration details
- CI/CD Patterns - Running bundle size checks in CI
- Electron Forge - ASAR and packaging configuration
Related skills
How it compares
Pick electron-best-practices over generic React skills when Electron-specific IPC security, electron-vite builds, and desktop packaging are the primary concerns.
FAQ
What Electron version does electron-best-practices target?
electron-best-practices targets Electron 20 and above where context isolation, sandbox mode, and disabled nodeIntegration are standard security defaults. Versions below 20 are explicitly out of scope.
What IPC pattern does electron-best-practices recommend?
electron-best-practices recommends invoke/handle over send/on for request-response IPC, exposing APIs through contextBridge with typed IpcChannelMap channels and success/data/error result wrappers.
Is Electron Best Practices safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.