DEV Community

A0mineTV
A0mineTV

Posted on

React’s `useSyncExternalStore` in Practice — Building a Cross-Tab Shopping Cart

React 18 brought Concurrency-safe rendering, but it also created new land-mines for anyone reading state that lives outside React.

useSyncExternalStore is the official escape hatch: a low-level hook that keeps UI and external state in perfect sync—even under concurrent renders or server hydration.

In this article we’ll:

  1. Recap why the hook exists.
  2. Build a tiny store that keeps a shopping cart in localStorage and broadcasts changes across tabs.
  3. Expose that store to React via useSyncExternalStore.
  4. See the benefits over a vanilla useEffect + useState approach.

1 Why useSyncExternalStore?

Problem How the hook solves it
Tearing – the DOM briefly shows inconsistent state when the store changes during a concurrent render useSyncExternalStore captures one snapshot per render pass for all components, then re-reads just before commit
Subscription boilerplate – every component rolls its own useEffect The hook handles subscribe / unsubscribe for you
SSR hydration mismatches An optional getServerSnapshot fills gaps on the server, avoiding hydration warnings
Library-agnostic Works with Redux, Zustand, event emitters, WebSocket clients, matchMedia, you name it

2 A real use-case: cross-tab shopping cart

If you add an item in Tab A, Tab B should update instantly—without a manual
refresh.

2.1 The external store (cartStore.ts)

// cartStore.ts
type CartItem = { id: string; qty: number };
type Cart = CartItem[];

const KEY = "cart";

function readCart(): Cart {
  try {
    return JSON.parse(localStorage.getItem(KEY) ?? "[]");
  } catch {
    return [];                // corrupted JSON fallback
  }
}

function writeCart(next: Cart) {
  localStorage.setItem(KEY, JSON.stringify(next));

  // Fire a synthetic "storage" event in *this* tab as well,
  // so every listener (even in the origin tab) hears it.
  window.dispatchEvent(new StorageEvent("storage", { key: KEY }));
}

export const cartStore = {
  /* 1️⃣ — Snapshot reader */
  getSnapshot: readCart,

  /* 2️⃣ — Subscribe / unsubscribe */
  subscribe(listener: () => void) {
    window.addEventListener("storage", listener);
    return () => window.removeEventListener("storage", listener);
  },

  /* 3️⃣ — Business API */
  add(item: CartItem) {
    const next = [...readCart()];
    const found = next.find(i => i.id === item.id);
    found ? (found.qty += item.qty) : next.push(item);
    writeCart(next);
  },

  clear() {
    writeCart([]);
  }
};
Enter fullscreen mode Exit fullscreen mode

2.2 Hooking it to React (useCart.ts)

// useCart.ts
import { useSyncExternalStore } from "react";
import { cartStore } from "./cartStore";

export function useCart() {
  return useSyncExternalStore(
    cartStore.subscribe,    // how to listen
    cartStore.getSnapshot,  // how to read
    () => []                // server-side snapshot (empty cart)
  );
}
Enter fullscreen mode Exit fullscreen mode

2.3 UI components

// CartBadge.tsx
import { useCart } from "./useCart";

export function CartBadge() {
  const cart = useCart();
  const totalQty = cart.reduce((sum, i) => sum + i.qty, 0);

  return (
    <button className="relative">
      🛒
      {totalQty > 0 && (
        <span className="absolute -right-2 -top-2 rounded-full bg-red-600 px-2 text-sm text-white">
          {totalQty}
        </span>
      )}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode
// AddToCartButton.tsx
import { cartStore } from "./cartStore";

export function AddToCartButton({ productId }: { productId: string }) {
  return (
    <button
      onClick={() => cartStore.add({ id: productId, qty: 1 })}
      className="rounded bg-blue-600 px-4 py-2 font-medium text-white"
    >
      Add to cart
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now open your app in two tabs:

  1. Click Add to cart in Tab A.
  2. Tab B (and the badge in Tab A) update instantly, no reload needed.
  3. No tearing: every component reads the same snapshot during any render pass.

3 Why not an ordinary useEffect?

A naïve version looks like this:

function useCart_naive() {
  const [cart, setCart] = useState(readCart());

  useEffect(() => {
    const listener = () => setCart(readCart());
    window.addEventListener("storage", listener);
    return () => window.removeEventListener("storage", listener);
  }, []);

  return cart;
}
Enter fullscreen mode Exit fullscreen mode

It works, but:

  • Tearing risk – if localStorage changes mid-render under React 18’s concurrent scheduler, components can read two different values in the same commit.
  • No server snapshot – hydration warnings when client state differs from SSR.
  • Re-creating listeners – every component using this hook adds its own storage handler unless you memoize the hook further.

useSyncExternalStore addresses all three in a single, officially supported API.

4 Takeaways

  • Use useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?) whenever React needs to mirror an external source of truth.
  • You get concurrency-safe reads, automatic clean-up, and seamless SSR hydration.
  • The pattern scales: swap localStorage for Redux, Zustand, a WebSocket client, or even window.matchMedia—only subscribe and getSnapshot change.

Conclusion

useSyncExternalStore is small but mighty: a single hook that bridges the gap between React’s declarative world and any outside source of truth—without tearing, without hydration headaches, and with minimal boilerplate. Once you start treating subscribe + snapshot as first-class citizens, connecting to localStorage, Redux, a WebSocket feed, or even matchMedia becomes a copy-paste affair instead of framework gymnastics.

Give it a spin: refactor one “effect-heavy” component to use this pattern and watch your cleanup logic disappear. If it saves you a bug (or three), share the demo, drop a comment, and let the community know how it went. Happy coding!

Top comments (0)