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:
- Recap why the hook exists.
- Build a tiny store that keeps a shopping cart in
localStorage
and broadcasts changes across tabs. - Expose that store to React via
useSyncExternalStore
. - 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([]);
}
};
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)
);
}
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>
);
}
// 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>
);
}
Now open your app in two tabs:
- Click Add to cart in Tab A.
- Tab B (and the badge in Tab A) update instantly, no reload needed.
- 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;
}
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 evenwindow.matchMedia
—onlysubscribe
andgetSnapshot
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)