Skip to content

How to Sync State Across Electron Windows

Sync state across all Electron windows using IPC — with any state manager on the renderer side.

You open a settings panel in a separate Electron window. Toggle dark mode. Close the window. The main window is still in light mode. Refresh — now it's dark. That one-second flicker, the stale UI, the "just refresh it" workaround — this is the multi-window state sync problem.

The core issue: Electron windows are isolated renderer processes. They don't share memory. Every piece of shared state — theme, language, user preferences — needs to cross the IPC bridge. Most teams end up with a tangle of ipcMain.handle / webContents.send calls that grow with every new piece of state.

This guide shows a structured approach: the main process is the single source of truth, renderers pull snapshots on demand, and a revision gate prevents stale updates. 3 runtime files, any state manager, all windows in sync. (For a side-by-side comparison with other Electron sync libraries, see the Electron Feature Matrix.)

TIP

View full source on GitHubelectron-main.ts, electron-preload.ts, electron-renderer.ts

Project structure

your-electron-app/
├── src/
│   ├── main.ts          # Main process — state + broadcaster + snapshot handler
│   ├── preload.ts       # Bridge via contextBridge
│   ├── renderer.ts      # Zustand store + sync setup
│   └── window.d.ts      # Type declarations for window.statesync
├── index.html
├── package.json
└── tsconfig.json

Installation

bash
npm install @statesync/electron @statesync/core

Plus the adapter for your state manager (e.g. @statesync/redux, @statesync/zustand, @statesync/jotai, @statesync/mobx, @statesync/pinia, etc.).

Architecture

All renderers receive every invalidation. The revision gate skips re-fetch only when the renderer already has the latest revision (e.g., two rapid invalidations where the first fetch already returned the latest state).

Step 1: Preload

Electron isolates renderer processes from Node.js APIs for security. contextBridge safely exposes the sync bridge to renderer code — a single exposeInMainWorld call:

typescript
// preload.ts
import { contextBridge, ipcRenderer } from 'electron';
import { createElectronBridge } from '@statesync/electron';

// State-sync bridge (invalidation + snapshot channels)
contextBridge.exposeInMainWorld(  
  'statesync',                    
  createElectronBridge(ipcRenderer), 
);                                

// Write channel for renderer → main updates
contextBridge.exposeInMainWorld('api', { 
  updateSettings: (patch: Record<string, unknown>) =>
    ipcRenderer.invoke('update-settings', patch), 
}); 

contextBridge proxy identity

Electron's contextBridge wraps every function in a new proxy on each access. This breaks ipcRenderer.removeListener() because the callback reference changes. createElectronBridge() solves this by returning an unsubscribe closure that captures the original listener reference in preload scope.

Security note

The bridge exposes invoke() for arbitrary IPC channels. If your main process has ipcMain.handle() registrations for privileged operations, consider adding channel validation. See Electron security docs for best practices.

Step 2: Main process

The main process holds the source of truth. Two primitives wire it up:

typescript
// main.ts
import { app, BrowserWindow, ipcMain } from 'electron';
import {
  createElectronBroadcaster,
  createElectronSnapshotHandler,
} from '@statesync/electron';

type AppSettings = { theme: 'light' | 'dark'; language: string; fontSize: number };
const ALLOWED_KEYS: ReadonlySet<string> = new Set<keyof AppSettings>(['theme', 'language', 'fontSize']);

let state: AppSettings = { theme: 'dark', language: 'en', fontSize: 14 };
let rev = 0;

// Broadcaster: push invalidation events to ALL windows
const broadcaster = createElectronBroadcaster({ 
  topic: 'settings', 
  getTargets: () => BrowserWindow.getAllWindows().map(w => w.webContents), 
}); 

// Snapshot handler: respond to "give me latest state" requests
const snapshotHandler = createElectronSnapshotHandler({ 
  topic: 'settings', 
  getSnapshot: () => ({ revision: String(rev), data: state }), 
  handle: ipcMain.handle.bind(ipcMain), 
  removeHandler: ipcMain.removeHandler.bind(ipcMain), 
}); 

// Write path: renderer → main via IPC
ipcMain.handle('update-settings', (_event, patch: Record<string, unknown>) => { 
  const sanitized: Partial<AppSettings> = {}; 
  for (const [key, value] of Object.entries(patch)) { 
    if (ALLOWED_KEYS.has(key)) (sanitized as Record<string, unknown>)[key] = value; 
  } 
  state = { ...state, ...sanitized }; 
  rev++; 
  broadcaster.invalidate(String(rev)); 
  return { ok: true }; 
}); 

// Cleanup on app quit
app.on('will-quit', () => { 
  snapshotHandler.dispose(); 
}); 

Step 3: Renderer — any state manager

This is where it gets interesting. The renderer setup is identical regardless of the state manager — only the applier line changes.

typescript
// renderer.ts — Redux
import { createElectronRevisionSync } from '@statesync/electron';
import { createReduxSnapshotApplier, withSnapshotHandling } from '@statesync/redux';
import { store } from './store'; // configureStore({ reducer: withSnapshotHandling(rootReducer) })

const sync = createElectronRevisionSync({
  topic: 'settings',
  bridge: window.statesync,
  applier: createReduxSnapshotApplier(store, { omitKeys: ['isLoading'] }), 
  onError(ctx) { 
    console.error(`[sync] phase=${ctx.phase}`, ctx.error); 
  }, 
});

await sync.start();

// Cleanup on page unload
window.addEventListener('beforeunload', () => sync.stop()); 
typescript
// renderer.ts — Zustand
import { createElectronRevisionSync } from '@statesync/electron';
import { createZustandSnapshotApplier } from '@statesync/zustand';
import { useSettingsStore } from './stores/settings';

const sync = createElectronRevisionSync({
  topic: 'settings',
  bridge: window.statesync,
  applier: createZustandSnapshotApplier(useSettingsStore, { omitKeys: ['isLoading'] }), 
  onError(ctx) { 
    console.error(`[sync] phase=${ctx.phase}`, ctx.error); 
  }, 
});

await sync.start();

// Cleanup on page unload
window.addEventListener('beforeunload', () => sync.stop()); 
typescript
// renderer.ts — Pinia
import { createElectronRevisionSync } from '@statesync/electron';
import { createPiniaSnapshotApplier } from '@statesync/pinia';
import { useSettingsStore } from './stores/settings';

const store = useSettingsStore();

const sync = createElectronRevisionSync({
  topic: 'settings',
  bridge: window.statesync,
  applier: createPiniaSnapshotApplier(store, { mode: 'patch', omitKeys: ['isSaving'] }), 
  onError(ctx) {
    console.error(`[sync] phase=${ctx.phase}`, ctx.error);
  },
});

await sync.start();
window.addEventListener('beforeunload', () => sync.stop());
typescript
// renderer.ts — Valtio
import { createElectronRevisionSync } from '@statesync/electron';
import { createValtioSnapshotApplier } from '@statesync/valtio';
import { settingsState } from './stores/settings';

const sync = createElectronRevisionSync({
  topic: 'settings',
  bridge: window.statesync,
  applier: createValtioSnapshotApplier(settingsState, { pickKeys: ['theme', 'language', 'fontSize'] }), 
  onError(ctx) {
    console.error(`[sync] phase=${ctx.phase}`, ctx.error);
  },
});

await sync.start();
window.addEventListener('beforeunload', () => sync.stop());
typescript
// renderer.ts — Svelte (writable store)
import { createElectronRevisionSync } from '@statesync/electron';
import { createSvelteSnapshotApplier } from '@statesync/svelte';
import { settingsStore } from './stores/settings'; // writable(...)

const sync = createElectronRevisionSync({
  topic: 'settings',
  bridge: window.statesync,
  applier: createSvelteSnapshotApplier(settingsStore), 
  onError(ctx) {
    console.error(`[sync] phase=${ctx.phase}`, ctx.error);
  },
});

await sync.start();
window.addEventListener('beforeunload', () => sync.stop());
typescript
// renderer.ts — Vue reactive
import { createElectronRevisionSync } from '@statesync/electron';
import { createVueSnapshotApplier } from '@statesync/vue';
import { settingsState } from './stores/settings'; // reactive({...})

const sync = createElectronRevisionSync({
  topic: 'settings',
  bridge: window.statesync,
  applier: createVueSnapshotApplier(settingsState), 
  onError(ctx) {
    console.error(`[sync] phase=${ctx.phase}`, ctx.error);
  },
});

await sync.start();
window.addEventListener('beforeunload', () => sync.stop());

Notice: the only line that differs is the applier. Everything else — topic, bridge, lifecycle — stays the same.

sync.start() subscribes to invalidation events and immediately fetches the initial snapshot, so the store is populated before the UI renders. Until start() resolves, the store holds its default values.

The onError callback receives a context with phase — possible values: start, subscribe, refresh, getSnapshot, apply. This tells you exactly where the failure occurred.

Step 4: Write path — renderer to main

Renderers are read-only sync consumers, but they can trigger state changes via a separate IPC channel. This keeps the write path explicit — the main process receives the patch, filters allowed keys, and applies the change:

typescript
// renderer.ts — write path (framework-agnostic)
await window.api.updateSettings({ theme: 'light' }); 
await window.api.updateSettings({ fontSize: 16 }); 

The call is the same regardless of state manager — window.api.updateSettings() is exposed via the preload bridge (Step 1) and handled by ipcMain.handle('update-settings') in the main process (Step 2). No direct store manipulation needed.

The flow: renderer calls window.api.updateSettings() → preload forwards via ipcRenderer.invoke('update-settings') → main process filters allowed keys, applies patch, increments revision, broadcasts invalidation → all renderers (including the caller) receive the update through the sync cycle.

TypeScript setup

Declare the bridge type so window.statesync is typed:

typescript
// src/window.d.ts
import type { ElectronStateSyncBridge } from '@statesync/electron';

declare global {
  interface Window {
    statesync: ElectronStateSyncBridge;
    api: {
      updateSettings: (patch: Record<string, unknown>) => Promise<{ ok: boolean }>;
    };
  }
}

export {};

Testing

The full example creates two windows automatically. To test:

  1. Start your Electron app (npm start or electron .)
  2. Both windows open with the same initial state
  3. In Window 1, call toggleTheme() → sends invoke('update-settings', { theme: 'light' })
  4. Main process applies the patch, increments revision ("0" → "1"), broadcasts invalidation
  5. Both windows receive invalidation → fetch snapshot → apply { theme: "light" }
Window 1: invoke('update-settings', { theme: 'light' })

Main: state.theme = "light", rev "0" → "1"

Main: broadcasts invalidation (rev "1") to all windows

Window 1: "1" > local "0" → fetches snapshot → applies { theme: "light" }
Window 2: "1" > local "0" → fetches snapshot → applies { theme: "light" }

Open DevTools in both windows to observe the sync cycle. The revision gate deduplicates bursts: if two rapid invalidations arrive (rev "1", rev "2"), but the first fetch already returns rev "2", the second invalidation is skipped ("2" is not newer than local "2").

Key points

  1. 3 runtime files: preload (bridge), main (broadcaster + handler), renderer (sync) — plus a .d.ts for TypeScript

  2. Main is source of truth: State lives in the main process; renderers pull snapshots for reads and send commands via IPC for writes

  3. Any state manager: Swap the applier one-liner — Redux, Zustand, Jotai, MobX, Pinia, Valtio, Svelte, or Vue

  4. Window lifecycle safe: createElectronBroadcaster handles destroyed webContents gracefully — no crashes if a window closes mid-broadcast

  5. Revision gate: During burst updates, renderers skip re-fetch when they already have the latest revision — no redundant IPC round-trips

Production tips

For high-frequency state changes (sliders, real-time data), add throttling to limit how often renderers re-fetch:

typescript
const sync = createElectronRevisionSync({
  topic: 'settings',
  bridge: window.statesync,
  applier: createZustandSnapshotApplier(useSettingsStore),
  throttling: { debounceMs: 50 }, 
  onError(ctx) {
    console.error(`[sync] phase=${ctx.phase}`, ctx.error);
  },
});

Other options worth knowing:

  • throttling.throttleMs — max interval between re-fetches during sustained bursts
  • shouldRefresh(event) — filter invalidation events before triggering a fetch (e.g., skip events from the current window)
  • logger — structured logging with debug, warn, error for observability

How does it compare?

Honest look at Electron state sync options:

@statesync/electronelectron-redux@zubridge/electronCustom IPC
Multi-window syncIPC pushIPC pushIPC pushDIY
State managerAny (adapter)ReduxZustand, ReduxAny
Burst deduplicationRevision gateDIY
TypeScriptNativeBundled typesNativeDIY
StatusActiveStable (v2.0.0)ActiveYou maintain it

All three libraries use a centralized main process as source of truth — which inherently serializes writes and prevents conflicts. The difference is in how renderers handle rapid updates: @statesync/electron adds a revision gate so renderers skip redundant re-fetches during bursts (e.g., slider dragging or real-time data). electron-redux and @zubridge/electron push full state on every change, which is simpler but uses more IPC bandwidth.

Worth noting

electron-store is often mentioned in this context, but it's a config persistence tool, not a real-time sync solution. It uses file watching for cross-process change detection.

electron-redux and @zubridge/electron push state over IPC with built-in adapters for their supported state managers. @statesync/electron is a transport layer — bring any state manager through an adapter, including mixing frameworks across windows.

See also

Released under the MIT License.