React WebSocket state with useSyncExternalStore #
Your dashboard keeps live WebSocket data in a module-level object and pushes it into components with useState and useEffect. Under React 18’s concurrent rendering, two components occasionally show different values for the same metric during a burst of updates — one rendered before a message arrived, one after — and every message re-renders every subscribed component even when the field it displays did not change. Both problems come from syncing an external, mutable source into React with effects. React ships a hook built for exactly this case: useSyncExternalStore lets components read from a store that lives outside React, guarantees a consistent snapshot across the whole render, and re-renders only the components whose selected value changed.
Root cause #
The effect-based pattern — subscribe in useEffect, call setState with each message — has a gap between the external source and React’s state. Concurrent rendering can pause a render partway through the tree, let a WebSocket message mutate the source, and resume. Components rendered before the pause show the old value, components rendered after show the new one, and the screen is briefly inconsistent. React’s documentation calls this tearing. It is rare with small updates and becomes visible with high-frequency streams, transitions and Suspense.
The second cost is granularity. If every component subscribes to “the socket” and stores the whole latest message in state, each message re-renders all of them. Memoization at the component level helps only if props are stable, which is hard when each message produces a new object.
Resolution #
Build a small store that owns the socket, keeps an immutable snapshot (a new object whenever data changes), and exposes subscribe and getSnapshot. Components call useSyncExternalStore through a selector hook, so each component re-renders only when the slice it reads changes by reference.
import { useCallback, useRef, useSyncExternalStore } from 'react';
type Prices = Readonly<Record<string, number>>;
interface Snapshot { status: 'connecting' | 'open' | 'closed'; prices: Prices; lastSeq: number }
const EMPTY: Snapshot = Object.freeze({ status: 'connecting', prices: Object.freeze({}), lastSeq: 0 });
class PriceStore {
private snapshot: Snapshot = EMPTY;
private listeners = new Set<() => void>();
private ws: WebSocket | null = null;
connect(url: string) {
if (this.ws) return; // one socket for the whole app
this.ws = new WebSocket(url);
this.ws.onopen = () => this.set({ ...this.snapshot, status: 'open' });
this.ws.onclose = () => { this.ws = null; this.set({ ...this.snapshot, status: 'closed' }); };
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data) as { seq: number; updates: Record<string, number> };
if (msg.seq <= this.snapshot.lastSeq) return; // stale or duplicate
// New objects only for what changed: untouched symbols keep their identity.
this.set({ ...this.snapshot, prices: { ...this.snapshot.prices, ...msg.updates }, lastSeq: msg.seq });
};
}
private set(next: Snapshot) {
this.snapshot = next;
for (const l of this.listeners) l();
}
// Both must be stable function references for useSyncExternalStore.
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = () => this.snapshot; // must return the SAME object until data changes
getServerSnapshot = () => EMPTY; // SSR and hydration read the empty state
}
export const priceStore = new PriceStore();
// Selector hook: re-render only when the selected value changes (Object.is).
export function usePriceStore<T>(selector: (s: Snapshot) => T): T {
const selectorRef = useRef(selector);
selectorRef.current = selector;
const getSelected = useCallback(() => selectorRef.current(priceStore.getSnapshot()), []);
const getServerSelected = useCallback(() => selectorRef.current(priceStore.getServerSnapshot()), []);
return useSyncExternalStore(priceStore.subscribe, getSelected, getServerSelected);
}
// Usage: this row re-renders only when AAPL's price changes.
export function PriceCell({ symbol }: { symbol: string }) {
const price = usePriceStore((s) => s.prices[symbol]);
return <td>{price?.toFixed(2) ?? '—'}</td>;
}
The rule that makes this work is that getSnapshot must return the same value for the same data. Returning a fresh object on every call — getSnapshot = () => ({ ...state }) — makes React think the store changed on every render and loops until it throws “The result of getSnapshot should be cached”. Keep one snapshot object and replace it only when a message changes something.
Selectors must also return stable values. (s) => s.prices[symbol] returns a number, which compares by value; a selector that builds a new array or object on every call, such as Object.entries(s.prices), re-renders on every message. For derived collections, memoize the derivation on the snapshot object, or select the underlying object and derive inside useMemo.
Where the socket is opened matters too. Calling priceStore.connect() from a top-level effect in your app root, once, keeps the connection independent of which components are mounted, avoiding the StrictMode double-connect problem covered in useWebSocket cleanup and teardown patterns.
Edge cases #
Server rendering. getServerSnapshot is required when components using the hook render on the server, and its value must match what the client renders during hydration. Returning the empty snapshot on both sides, then connecting after hydration, avoids mismatch errors. Frameworks with server components need more care; see using WebSockets in the Next.js App Router.
Synchronous re-renders. Updates delivered through useSyncExternalStore cannot be marked as transitions — React renders them synchronously to guarantee consistency. For very high-frequency streams, batch messages into one snapshot update per animation frame, as described in batching WebSocket updates with requestAnimationFrame.
Multiple sockets. One store per logical stream keeps selectors simple. Avoid a single mega-store holding every socket’s data unless components frequently combine them.
Verification #
Use the React DevTools Profiler with “Highlight updates when components render” enabled. With a live stream running, only the cells whose values changed should flash. Record a profile over a few seconds and check the “Why did this render?” reason: it should read “Hook changed” for the affected components, and unaffected rows should not appear at all.
To test for tearing, render many consumers of the same value inside a startTransition while a fake socket emits rapid updates, and assert that all rendered values in a single commit are identical. The fake-socket setup from mocking WebSockets in Vitest works for this.
Operational checklist #
FAQ #
When should I use useSyncExternalStore instead of Zustand? #
Zustand and React-Redux use useSyncExternalStore internally, so they give you the same guarantees with more features. Use the hook directly when you want no dependency and your store is small, or when you are writing a library. Use a store library when you want middleware, devtools or complex derived state — see fixing Zustand stale WebSocket subscriptions.
Why do I get “The result of getSnapshot should be cached”? #
Your getSnapshot returns a new object or array each time it is called. React calls it repeatedly and compares results with Object.is; a new object every time looks like constant change. Return a stored snapshot and replace it only when data changes.
Does this replace a useWebSocket hook? #
It replaces the part of a hook that holds received data. You still need connection management — reconnects, heartbeats, teardown — which the store’s connect method would own, as in building a useWebSocket React hook with TypeScript.
Can selectors take arguments? #
Yes. Close over them, as PriceCell does with symbol. The ref-based selector hook keeps the subscription stable even when the selector function is recreated each render.
Related #
- Building a useWebSocket React Hook with TypeScript — connection lifecycle in a hook.
- Sharing One WebSocket Across React Components — the singleton this store implements.
- WebSocket Context Provider in React — scoping stores to a subtree.
- Batching WebSocket Updates with requestAnimationFrame — capping snapshot updates per frame.
Back to React WebSocket Custom Hooks.