DEV Community

Cover image for Electron + React/Vue: A Practical Guide to Integrating a Modern Frontend Framework in the Renderer
mp3sss
mp3sss

Posted on AI-assisted

Electron + React/Vue: A Practical Guide to Integrating a Modern Frontend Framework in the Renderer

Most Electron apps start life as a web SPA that someone wrapped in a window. That works for a demo, but the renderer in a desktop app has different constraints: it loads from the filesystem or a local dev server, it talks to a privileged main process through a narrow bridge, it must survive CSP and sandbox rules, and it ships as a frozen bundle inside an installer rather than a CDN. Integrating React or Vue well means treating the framework as the renderer layer of a three-process architecture, not as a website that happens to run on the desktop. This guide covers the project layout, the typed preload bridge, dev-mode hot reload, production packaging, where state and IPC belong, and the pitfalls that only appear after you ship an installer.

TL;DR

  • Keep the three layers separate: main owns privileges and Node access, preload exposes a narrow typed bridge, the framework renderer owns UI and state. Never let the renderer reach Node directly.
  • Use a bundler that knows Electron: Vite (or electron-vite) builds main, preload, and renderer as separate targets; the renderer bundle is what ships in the asar.
  • Expose a minimal contextBridge API with explicit method names and payload types. The framework calls window.api.*, never require or ipcRenderer.
  • In dev, load the Vite dev server so HMR works; in production, load the built index.html from disk. The main process switches between the two by an environment flag.
  • Put durable state in main or a local store, keep framework state ephemeral; cross-window sync goes through main events, not shared module singletons.
  • The two traps that cost me days: (1) enabling nodeIntegration or disabling contextIsolation to make a library "just work" — it erases the security boundary; (2) routing and assets configured for a web base path, which breaks when the app loads over file:// or a custom protocol.

Table of contents

  1. Why a framework in Electron differs from a web SPA
  2. The three-layer architecture
  3. Project layout and build setup
  4. The preload bridge: a typed, minimal API
  5. Wiring React or Vue to the bridge
  6. Dev mode: hot reload against the Electron renderer
  7. Production build and packaging the renderer bundle
  8. State management and where IPC belongs
  9. Routing, deep links, and multi-window state
  10. Common pitfalls
  11. Checklist

Why a framework in Electron differs from a web SPA

A browser SPA assumes a public origin, a network, and a reload button. An Electron renderer assumes none of those by default:

  • It runs next to a privileged process. The renderer can request file access, shell commands, and system dialogs through main. That power has to flow through a controlled channel, not a direct Node handle.
  • It loads locally. In production the page comes from file://, a custom protocol, or a bundled dev server — not a web origin. Asset paths, router base URLs, and absolute URLs that work on the web often break here.
  • It is sandboxed by default. Modern Electron recommendations run the renderer with contextIsolation: true and sandbox: true, which removes the Node globals the web version of your code expects.
  • It ships frozen. The bundle inside the installer is fixed until the next release; there is no CDN cache busting and no "reload to get the new JS" assumption.

The framework itself does not care whether it mounts in Chromium on the web or in an Electron window. The surrounding integration does.


The three-layer architecture

Layer Process Has Node access? Owns Talks to
Main Main process Yes Windows, menus, dialogs, file system, network, stores Renderers via IPC
Preload Per renderer, sandboxed Limited, whitelisted The typed bridge between main and renderer ipcRenderer to main, contextBridge to window
Renderer Renderer process No React/Vue UI, ephemeral state, user interaction window.api.* exposed by preload

The rule that keeps this safe: the framework code imports components and hooks, and calls bridge methods. It never imports electron, node:fs, or ipcRenderer. If a feature needs a Node capability, you add a method to the bridge and a matching handler in main.


Project layout and build setup

A clean layout separates the three targets so the bundler can compile each with the right settings:

src/
  main/
    index.ts            # app lifecycle, BrowserWindow, IPC handlers
  preload/
    bridge.ts           # contextBridge exposure, channel names, types
  renderer/
    main.tsx            # React/Vue entry, mounts the framework
    App.tsx
    api/
      client.ts         # typed wrapper over window.api
  shared/
    types.ts            # payload types shared by main and renderer
Enter fullscreen mode Exit fullscreen mode

With Vite (or electron-vite), each target has its own build config. The renderer is a normal web bundle; main and preload are Node/ESM targets that get bundled separately.

// vite.renderer.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; // or @vitejs/plugin-vue

export default defineConfig({
  plugins: [react()], // replace with vue() for a Vue renderer
  base: "./", // relative asset paths so the bundle loads over file:// or a custom protocol
  build: {
    outDir: "dist/renderer",
    emptyOutDir: true,
  },
  server: {
    port: 5173,
    strictPort: true,
  },
});
Enter fullscreen mode Exit fullscreen mode

The base: "./" detail matters: an absolute /assets/... path resolves against the install root in production and breaks the moment the app is loaded from a non-root protocol.


The preload bridge: a typed, minimal API

The preload script runs before the renderer page and exposes a small, explicit surface on window. Keep channel names and payload types in a shared file so main and renderer agree on the contract.

// src/shared/types.ts
export interface Note {
  id: string;
  title: string;
  body: string;
  updatedAt: number;
}

export interface NotesApi {
  list(): Promise<Note[]>;
  save(note: Note): Promise<Note>;
  remove(id: string): Promise<void>;
  onChanged(cb: (notes: Note[]) => void): () => void;
}
Enter fullscreen mode Exit fullscreen mode
// src/preload/bridge.ts
import { contextBridge, ipcRenderer } from "electron";
import type { NotesApi, Note } from "../shared/types";

const api: NotesApi = {
  list: () => ipcRenderer.invoke("notes:list"),
  save: (note: Note) => ipcRenderer.invoke("notes:save", note),
  remove: (id: string) => ipcRenderer.invoke("notes:remove", id),
  onChanged(cb) {
    const listener = (_event: unknown, notes: Note[]) => cb(notes);
    ipcRenderer.on("notes:changed", listener);
    return () => ipcRenderer.removeListener("notes:changed", listener);
  },
};

contextBridge.exposeInMainWorld("api", api);
Enter fullscreen mode Exit fullscreen mode
// src/main/notes-handlers.ts
import { ipcMain } from "electron";
import type { Note } from "../shared/types";

export function registerNotesHandlers(getStore: () => {
  list(): Note[];
  save(note: Note): Note;
  remove(id: string): void;
  broadcast(notes: Note[]): void;
}) {
  const store = getStore();
  ipcMain.handle("notes:list", () => store.list());
  ipcMain.handle("notes:save", (_e, note: Note) => store.save(note));
  ipcMain.handle("notes:remove", (_e, id: string) => store.remove(id));
  // broadcast pushes changes to every renderer; main owns the fan-out
}
Enter fullscreen mode Exit fullscreen mode

Main validates every incoming payload before touching the store. The bridge is a contract, not a pass-through: add schema/shape checks in the handler, and never trust a renderer that could be compromised.

For TypeScript, declare the global once so the renderer sees window.api without casting everywhere:

// src/renderer/global.d.ts
import type { NotesApi } from "../shared/types";

declare global {
  interface Window {
    api: NotesApi;
  }
}

export {};
Enter fullscreen mode Exit fullscreen mode

Wiring React or Vue to the bridge

The framework talks to window.api through a thin client, usually wrapped in a hook or composable so components do not call IPC channels directly.

React example:

// src/renderer/hooks/useNotes.ts
import { useEffect, useState } from "react";
import type { Note } from "../../shared/types";

export function useNotes() {
  const [notes, setNotes] = useState<Note[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let off: (() => void) | undefined;
    window.api.list().then(setNotes).finally(() => setLoading(false));
    off = window.api.onChanged(setNotes);
    return () => off?.();
  }, []);

  return {
    notes,
    loading,
    save: (note: Note) => window.api.save(note),
    remove: (id: string) => window.api.remove(id),
  };
}
Enter fullscreen mode Exit fullscreen mode

Vue example (composable):

// src/renderer/composables/useNotes.ts
import { ref, onMounted, onUnmounted } from "vue";
import type { Note } from "../../shared/types";

export function useNotes() {
  const notes = ref<Note[]>([]);
  const loading = ref(true);
  let off: (() => void) | undefined;

  onMounted(async () => {
    notes.value = await window.api.list();
    loading.value = false;
    off = window.api.onChanged((n) => (notes.value = n));
  });
  onUnmounted(() => off?.());

  return {
    notes,
    loading,
    save: (note: Note) => window.api.save(note),
    remove: (id: string) => window.api.remove(id),
  };
}
Enter fullscreen mode Exit fullscreen mode

Components render from framework state; mutations go through the bridge; main pushes notes:changed back and the state refreshes through the subscription. This keeps the data flow one-directional and easy to reason about.


Dev mode: hot reload against the Electron renderer

In development you want Vite's HMR, but Electron needs to load the dev server URL rather than a built file. Main decides which to load based on an environment flag:

// src/main/index.ts
import { app, BrowserWindow } from "electron";
import path from "node:path";

const isDev = !app.isPackaged;
const RENDERER_DEV_URL = "http://localhost:5173";

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

  if (isDev) {
    win.loadURL(RENDERER_DEV_URL);
    win.webContents.openDevTools();
  } else {
    win.loadFile(path.join(__dirname, "../renderer/index.html"));
  }
}

app.whenReady().then(createWindow);
Enter fullscreen mode Exit fullscreen mode

Two processes run in dev: the Vite dev server serves the renderer with HMR, and Electron launches against it. When you change a component, Vite pushes the update and the window reloads the module without restarting the app. Main and preload changes still require an Electron restart; renderer changes hot-reload.

CSP in dev can be looser to allow the localhost HMR socket, but production CSP must tighten script-src and connect-src to bundled origins.


Production build and packaging the renderer bundle

The production flow builds all three targets, then the packager reads the output:

  1. Bundle the renderer to static HTML/JS/CSS in dist/renderer.
  2. Bundle main and preload to Node-compatible output.
  3. Point the packager (electron-builder or similar) at the built entry and let it copy the renderer into the asar.

Common asset gotchas:

  • Import images and fonts through the bundler so they are hashed and emitted next to the JS; do not reference raw paths in public/ that assume a web root.
  • Set the renderer router's base to match the asset base; otherwise client-side routes resolve against the install path.
  • Verify the built index.html loads over file:// or your custom protocol in a packaged build, not only in the dev server.

A useful pre-package check: build once, launch the packaged output on a clean machine, and confirm the first paint does not depend on the dev server or any remote CDN.


State management and where IPC belongs

Concern Lives where Why
UI state, form drafts, toggles Framework store (React context/Zustand, Vue ref/Pinia) Ephemeral, rebuilt on reload, fast to read
Derived/computed values Framework selector/computed Pure function of current state
User records, settings, offline queue Main process or local store Durable, shared across windows, survives reload
Side effects: file IO, shell, network, dialogs Main, via bridge method Needs privileges the renderer must not hold
Cross-window sync Main broadcast events Renderers cannot share module singletons

Resist the urge to put a durable database directly in the renderer. Even where IndexedDB works, a renderer store is per-origin and per-window; the source of truth that multiple windows read and write belongs in main.


Routing, deep links, and multi-window state

Client-side routers work in Electron, but they need a base that matches how the page is loaded. Hash-based routing is the safest default for file://-loaded apps; history routing works cleanly when you register a custom protocol or control the dev URL.

For multiple windows, each renderer is a separate process with its own framework instance. Do not assume module state is shared: open a second window and it remounts fresh. To keep windows consistent, main holds the source state and broadcasts change events; every renderer subscribes and refreshes. Deep links (myapp://notes/123) are handled in main's protocol/second-instance handler, which tells the focused window which record to show.


Common pitfalls

Pitfall What happens Fix
nodeIntegration: true to use a library Renderer can reach Node; one XSS becomes full host compromise Keep it off; expose only the needed bridge methods
Disabling contextIsolation Preload globals leak into the renderer's prototype chain Keep isolation on; communicate only through contextBridge
Absolute asset/base paths Blank screen in packaged build, assets 404 over file:// Use relative base: "./" and bundle assets through Vite
Router base set for web hosting Deep links refresh to a broken path Match router base to the Electron load scheme; prefer hash routing
Durable state in renderer store Second window diverges; reload loses unsynced records Keep source of truth in main/local store; sync via events
Calling ipcRenderer from framework code Couples UI to Electron; breaks the security boundary Wrap everything in the window.api bridge
Dev CSP carried into production Loose connect-src allows remote scripts; dev relaxations ship Build a production CSP with restricted script-src/connect-src
Relying on remote CDN for first paint App renders blank offline or on restricted networks Bundle the framework and runtime locally; treat remote assets as optional

Checklist

  • [ ] Main, preload, and renderer are separate build targets with clear boundaries.
  • [ ] contextIsolation: true, nodeIntegration: false, sandbox: true on every webPreferences.
  • [ ] The renderer only calls window.api.*; no direct electron/ipcRenderer/node: imports in framework code.
  • [ ] Bridge methods and payload types are defined in a shared contract file and validated in main handlers.
  • [ ] Dev loads the Vite dev server with HMR; production loads the built bundle from disk.
  • [ ] Renderer base and router base are relative or matched to the Electron load scheme.
  • [ ] Durable records live in main or a local store; cross-window changes broadcast through main.
  • [ ] Production CSP restricts scripts and connections; dev-only relaxations are not shipped.
  • [ ] Images/fonts are bundled through the build, not referenced by web-root paths.
  • [ ] A clean packaged build renders the first paint without the dev server or a remote CDN.

Top comments (0)