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.

Tearing with an effect-synced source Component A reads price 101, the render yields, a WebSocket message changes the store to 102, and component B reads 102, so the committed screen shows two different prices. Tearing with an effect-synced source WebSocket Store Component A Component B render reads price 101 render yields (concurrent) message: price 102 render reads price 102 screen: A=101, B=102 useSyncExternalStore detects the change during render and re-renders synchronously so both read the same snapshot
An interruptible render plus a mutable source equals an inconsistent screen.

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.

Component re-renders per single-symbol update With effect-driven state every row re-renders on each message, while selector subscriptions re-render only the row whose price changed. Component re-renders per single-symbol update one row per symbol, one symbol changes useEffect + setState selector hook 0 200 400 600 10 symbols 100 100 symbols 500 500 symbols
Selector subscriptions make render cost proportional to what changed, not to what is on screen.

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.

Ways to feed WebSocket data into React Comparison of useEffect with useState, a Context value, useSyncExternalStore and store libraries on tearing safety, render granularity and server rendering support. Ways to feed WebSocket data into React Tearing-safe Fine-grained SSR story useEffect + useState no no effects skip SSR Context value yes no: all consumers provider default useSyncExternalStore yes with selectors getServerSnapshot Zustand / Redux yes (uses it) with selectors library-specific Zustand and React-Redux use useSyncExternalStore internally, so they inherit its guarantees
The hook is the primitive that store libraries are built on — use it directly or through one.

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.

Back to React WebSocket Custom Hooks.