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 GitHub — electron-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.jsonInstallation
npm install @statesync/electron @statesync/corePlus 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:
// 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:
// 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.
// 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()); // 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()); // 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());// 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());// 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());// 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:
// 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:
// 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:
- Start your Electron app (
npm startorelectron .) - Both windows open with the same initial state
- In Window 1, call
toggleTheme()→ sendsinvoke('update-settings', { theme: 'light' }) - Main process applies the patch, increments revision ("0" → "1"), broadcasts invalidation
- 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
3 runtime files: preload (bridge), main (broadcaster + handler), renderer (sync) — plus a
.d.tsfor TypeScriptMain is source of truth: State lives in the main process; renderers pull snapshots for reads and send commands via IPC for writes
Any state manager: Swap the
applierone-liner — Redux, Zustand, Jotai, MobX, Pinia, Valtio, Svelte, or VueWindow lifecycle safe:
createElectronBroadcasterhandles destroyedwebContentsgracefully — no crashes if a window closes mid-broadcastRevision 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:
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 burstsshouldRefresh(event)— filter invalidation events before triggering a fetch (e.g., skip events from the current window)logger— structured logging withdebug,warn,errorfor observability
How does it compare?
Honest look at Electron state sync options:
| @statesync/electron | electron-redux | @zubridge/electron | Custom IPC | |
|---|---|---|---|---|
| Multi-window sync | IPC push | IPC push | IPC push | DIY |
| State manager | Any (adapter) | Redux | Zustand, Redux | Any |
| Burst deduplication | Revision gate | — | — | DIY |
| TypeScript | Native | Bundled types | Native | DIY |
| Status | Active | Stable (v2.0.0) | Active | You 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
- @statesync/electron — transport adapter API
- @statesync/redux — Redux adapter
- @statesync/zustand — Zustand adapter
- @statesync/pinia — Pinia adapter
- @statesync/valtio — Valtio adapter
- @statesync/svelte — Svelte adapter
- @statesync/vue — Vue adapter
- Multi-window patterns — cross-window architecture
- Electron Ecosystem Comparison — feature matrix and architecture ranking
