React + Zustand
Complete example of syncing Zustand store across browser tabs.
Use case
Shopping cart that stays in sync across multiple browser tabs. When user adds item in one tab, all other tabs update instantly.
Store definition
typescript
// stores/cart.ts
import { create } from 'zustand';
export interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
// UI state (not synced)
isLoading: boolean;
error: string | null;
// Actions
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
}
export const useCartStore = create<CartState>((set, get) => ({
items: [],
isLoading: false,
error: null,
addItem: (item) => {
const existing = get().items.find((i) => i.id === item.id);
if (existing) {
set({
items: get().items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
});
} else {
set({ items: [...get().items, { ...item, quantity: 1 }] });
}
},
removeItem: (id) => {
set({ items: get().items.filter((i) => i.id !== id) });
},
updateQuantity: (id, quantity) => {
if (quantity <= 0) {
get().removeItem(id);
return;
}
set({
items: get().items.map((i) => (i.id === id ? { ...i, quantity } : i)),
});
},
clearCart: () => set({ items: [] }),
}));Sync setup
typescript
// sync/cart-sync.ts
import { createRevisionSync, createConsoleLogger } from '@statesync/core';
import type { Revision } from '@statesync/core';
import { createZustandSnapshotApplier } from '@statesync/zustand';
import {
createPersistenceApplier,
createLocalStorageBackend,
loadPersistedSnapshot,
} from '@statesync/persistence';
import { useCartStore, type CartItem } from '../stores/cart';
// Revision tracking
let currentRevision = 0;
function getRevision(): Revision {
return currentRevision.toString() as Revision;
}
function incrementRevision(): Revision {
currentRevision++;
localStorage.setItem('cart:revision', currentRevision.toString());
return currentRevision.toString() as Revision;
}
// Initialize revision from localStorage
const savedRevision = localStorage.getItem('cart:revision');
if (savedRevision) {
currentRevision = parseInt(savedRevision, 10);
}
// BroadcastChannel for cross-tab communication
const channel = new BroadcastChannel('cart-sync');
// Subscriber: listen for invalidation events from other tabs
const subscriber = {
async subscribe(handler: (event: { topic: string; revision: string }) => void) {
const listener = (e: MessageEvent) => {
if (e.data?.type === 'invalidation') {
handler({ topic: e.data.topic, revision: e.data.revision });
}
};
channel.addEventListener('message', listener);
return () => channel.removeEventListener('message', listener);
},
};
// Provider: get current cart state
const provider = {
async getSnapshot() {
const state = useCartStore.getState();
return {
revision: getRevision(),
data: { items: state.items },
};
},
};
// Storage backend for persistence
const storage = createLocalStorageBackend<{ items: CartItem[] }>({
key: 'cart-state',
});
// Applier with persistence
const innerApplier = createZustandSnapshotApplier(useCartStore, {
mode: 'patch',
omitKeys: ['isLoading', 'error', 'addItem', 'removeItem', 'updateQuantity', 'clearCart'],
});
const applier = createPersistenceApplier({
storage,
applier: innerApplier,
throttling: { debounceMs: 300 },
crossTabSync: {
channelName: 'cart-sync',
receiveUpdates: true,
broadcastSaves: true,
},
});
// Create sync handle
export const cartSync = createRevisionSync({
topic: 'cart',
subscriber,
provider,
applier,
logger: createConsoleLogger({ debug: true }),
onError(ctx) {
console.error(`Cart sync error [${ctx.phase}]:`, ctx.error);
useCartStore.setState({ error: `Sync failed: ${ctx.phase}` });
},
});
// Broadcast changes to other tabs
export function broadcastCartChange() {
const revision = incrementRevision();
channel.postMessage({
type: 'invalidation',
topic: 'cart',
revision,
});
}
// Initialize: load from cache and start sync
export async function initCartSync() {
// Load cached state first (instant UI)
const cached = await loadPersistedSnapshot(storage, innerApplier);
if (cached) {
console.log('Restored cart from cache, revision:', cached.revision);
}
// Start real-time sync
await cartSync.start();
}
// Cleanup
export function stopCartSync() {
applier.dispose();
cartSync.stop();
channel.close();
}React hooks
typescript
// hooks/useCartSync.ts
import { useEffect } from 'react';
import { initCartSync, stopCartSync, broadcastCartChange } from '../sync/cart-sync';
import { useCartStore } from '../stores/cart';
export function useCartSync() {
useEffect(() => {
initCartSync();
return () => stopCartSync();
}, []);
}
export function useCart() {
const { items, isLoading, error, addItem, removeItem, updateQuantity, clearCart } =
useCartStore();
// Wrap actions to broadcast changes
const addItemAndSync = (item: Parameters<typeof addItem>[0]) => {
addItem(item);
broadcastCartChange();
};
const removeItemAndSync = (id: string) => {
removeItem(id);
broadcastCartChange();
};
const updateQuantityAndSync = (id: string, quantity: number) => {
updateQuantity(id, quantity);
broadcastCartChange();
};
const clearCartAndSync = () => {
clearCart();
broadcastCartChange();
};
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
return {
items,
isLoading,
error,
total,
itemCount,
addItem: addItemAndSync,
removeItem: removeItemAndSync,
updateQuantity: updateQuantityAndSync,
clearCart: clearCartAndSync,
};
}Components
tsx
// components/Cart.tsx
import { useCart, useCartSync } from '../hooks/useCartSync';
export function CartProvider({ children }: { children: React.ReactNode }) {
useCartSync();
return <>{children}</>;
}
export function Cart() {
const { items, total, itemCount, removeItem, updateQuantity, clearCart, error } = useCart();
if (error) {
return <div className="error">Sync error: {error}</div>;
}
return (
<div className="cart">
<h2>Cart ({itemCount} items)</h2>
{items.length === 0 ? (
<p>Your cart is empty</p>
) : (
<>
<ul>
{items.map((item) => (
<li key={item.id}>
<span>{item.name}</span>
<span>${item.price.toFixed(2)}</span>
<input
type="number"
min="1"
value={item.quantity}
onChange={(e) => updateQuantity(item.id, parseInt(e.target.value, 10))}
/>
<button onClick={() => removeItem(item.id)}>Remove</button>
</li>
))}
</ul>
<div className="total">
<strong>Total: ${total.toFixed(2)}</strong>
</div>
<button onClick={clearCart}>Clear Cart</button>
</>
)}
</div>
);
}
export function AddToCartButton({ product }: { product: { id: string; name: string; price: number } }) {
const { addItem } = useCart();
return (
<button onClick={() => addItem(product)}>
Add to Cart
</button>
);
}App entry
tsx
// App.tsx
import { CartProvider, Cart, AddToCartButton } from './components/Cart';
const products = [
{ id: '1', name: 'Widget', price: 9.99 },
{ id: '2', name: 'Gadget', price: 19.99 },
{ id: '3', name: 'Gizmo', price: 29.99 },
];
export default function App() {
return (
<CartProvider>
<div className="app">
<h1>Shop</h1>
<div className="products">
{products.map((product) => (
<div key={product.id} className="product">
<h3>{product.name}</h3>
<p>${product.price.toFixed(2)}</p>
<AddToCartButton product={product} />
</div>
))}
</div>
<Cart />
</div>
</CartProvider>
);
}Testing
Open your app in two browser tabs. Add items in one tab — they appear in the other tab instantly.
Tab 1: Add "Widget" to cart
Tab 2: Cart updates automatically (Widget x1)
Tab 2: Add "Gadget" to cart
Tab 1: Cart updates automatically (Widget x1, Gadget x1)
Close Tab 1, reopen
Tab 1: Cart restored from localStorage (Widget x1, Gadget x1)Key points
UI state excluded:
isLoading,error, and action functions are not synced viaomitKeysPersistence: Cart persists to localStorage, survives page refresh
Cross-tab sync: BroadcastChannel notifies other tabs of changes
Revision tracking: Prevents stale updates from overwriting newer data
Instant UI: Cache loads before sync starts for immediate feedback
See also
- @statesync/zustand — adapter API reference
- @statesync/persistence — caching and cross-tab sync
- Writing state — write path patterns
