Cross-Tab Sync
effstate supports synchronizing state machine state across browser tabs and windows. This is useful for:
- Keeping UI consistent across multiple tabs
- Persisting state to localStorage
- Real-time collaboration features
How It Works
Cross-tab sync uses a leader election pattern:
- The newest tab/window becomes the leader
- The leader saves state changes to localStorage
- Other tabs listen for storage events and sync their state
- When you focus a tab, it becomes the new leader
This prevents race conditions where multiple tabs try to write simultaneously.
Basic Setup
import { createCrossTabSync } from "@/lib/cross-tab-leader";import { encodeSnapshot, decodeSnapshot, interpret } from "effstate";import { Schema } from "effect";
const STORAGE_KEY = "myApp:machineState";
// Create the cross-tab sync coordinatorconst crossTabSync = createCrossTabSync({ storageKey: STORAGE_KEY, onSave: () => { // Called when this tab is leader and should save const snapshot = actor.getSnapshot(); const encoded = Schema.encodeSync(SnapshotSchema)(snapshot); localStorage.setItem(STORAGE_KEY, JSON.stringify(encoded)); }, onSync: () => { // Called when another tab saved and we should sync const stored = localStorage.getItem(STORAGE_KEY); if (stored) { const decoded = Schema.decodeSync(SnapshotSchema)(JSON.parse(stored)); actor._syncSnapshot(decoded); } }, throttleMs: 500, // Optional: throttle saves (default: 500ms)});
// Subscribe to state changesactor.subscribe(() => { crossTabSync.saveIfLeader(); // Only saves if this tab is leader});CrossTabSync API
interface CrossTabSync { /** Save state if this window is the leader (throttled) */ saveIfLeader: () => void; /** Check if this window is currently the leader */ isLeader: () => boolean; /** Manually claim leadership */ claimLeadership: () => void; /** Clean up event listeners */ destroy: () => void;}With Parent-Child Machines
When syncing machines with children, save and restore child state too:
const STORAGE_KEY = "hamsterWheel:state";
// Schema for persisted state including childrenconst PersistedStateSchema = Schema.Struct({ parent: Schema.Struct({ value: Schema.Literal("idle", "running", "stopping"), context: ParentContextSchema, }), children: Schema.Struct({ leftDoor: Schema.optional(ChildSnapshotSchema), rightDoor: Schema.optional(ChildSnapshotSchema), }),});
const saveState = (actor: MachineActor) => { const parentSnapshot = actor.getSnapshot();
// Get child snapshots const children: Record<string, Snapshot> = {}; actor.children.forEach((child, id) => { children[id] = child.getSnapshot(); });
const state = { parent: { value: parentSnapshot.value, context: parentSnapshot.context }, children, };
const encoded = Schema.encodeSync(PersistedStateSchema)(state); localStorage.setItem(STORAGE_KEY, JSON.stringify(encoded));};
const loadState = () => { const stored = localStorage.getItem(STORAGE_KEY); if (!stored) return null;
const decoded = Schema.decodeSync(PersistedStateSchema)(JSON.parse(stored));
const snapshot = { value: decoded.parent.value, context: decoded.parent.context, event: null, };
const childSnapshots = new Map(); Object.entries(decoded.children).forEach(([id, child]) => { if (child) { childSnapshots.set(id, { value: child.value, context: child.context, event: null }); } });
return { snapshot, childSnapshots };};
// Restore with child snapshotsconst persisted = loadState();const actor = persisted ? yield* interpret(machine, { snapshot: persisted.snapshot, childSnapshots: persisted.childSnapshots, }) : yield* interpret(machine);
// Subscribe to both parent and childrenactor.subscribe(() => crossTabSync.saveIfLeader());actor.children.forEach((child) => { child.subscribe(() => crossTabSync.saveIfLeader());});Using _syncSnapshot
The _syncSnapshot method allows updating a running actor’s state from external data:
// Sync parent state onlyactor._syncSnapshot(newSnapshot);
// Sync parent and child statesactor._syncSnapshot(parentSnapshot, childSnapshotsMap);This is useful when:
- Receiving state from another tab via storage event
- Receiving state from a server (real-time collaboration)
- Restoring state from a backup
Full Example with React
import { Atom } from "@effect-atom/atom-react";import { createCrossTabSync } from "@/lib/cross-tab-leader";
const STORAGE_KEY = "counter:state";
let currentActor: MachineActor | null = null;
const crossTabSync = createCrossTabSync({ storageKey: STORAGE_KEY, onSave: () => { if (currentActor) { const snapshot = currentActor.getSnapshot(); localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); } }, onSync: () => { if (currentActor) { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { currentActor._syncSnapshot(JSON.parse(stored)); } } },});
const actorAtom = appRuntime .atom( Effect.gen(function* () { const service = yield* CounterMachine;
// Load persisted state if available const stored = localStorage.getItem(STORAGE_KEY); const persisted = stored ? JSON.parse(stored) : null;
const actor = persisted ? yield* interpret(service.machine, { snapshot: persisted }) : yield* interpret(service.machine);
// Store reference for cross-tab sync currentActor = actor;
// Save on state changes (only if leader) actor.subscribe(() => crossTabSync.saveIfLeader());
return actor; }).pipe(Effect.provide(CounterMachine.Default)) ) .pipe(Atom.keepAlive);Schema-Based Serialization
Use Effect Schema for type-safe serialization, especially for complex types like Date:
const ContextSchema = Schema.Struct({ count: Schema.Number, lastUpdated: Schema.DateFromString, // Serializes Date <-> string});
// When savingconst encoded = Schema.encodeSync(ContextSchema)(context);// { count: 5, lastUpdated: "2024-01-15T10:30:00.000Z" }
// When loadingconst decoded = Schema.decodeSync(ContextSchema)(stored);// { count: 5, lastUpdated: Date object }Cleanup
Call destroy() when unmounting to clean up event listeners:
// In React useEffect cleanupuseEffect(() => { return () => { crossTabSync.destroy(); };}, []);Try It Out
Open the demo in multiple browser tabs and watch the hamster wheel and garage doors sync across all of them!