Skip to content

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

  1. UI state excluded: isLoading, error, and action functions are not synced via omitKeys

  2. Persistence: Cart persists to localStorage, survives page refresh

  3. Cross-tab sync: BroadcastChannel notifies other tabs of changes

  4. Revision tracking: Prevents stale updates from overwriting newer data

  5. Instant UI: Cache loads before sync starts for immediate feedback

See also

Released under the MIT License.