How would you prevent unnecessary re-renders in a dashboard with live updates?
Slice state by widget, use selectors with referential stability, isolate live-update components behind their own subscriptions, and memoize where measurement justifies it.
A live dashboard — trading desk, observability board, multiplayer presence — is the canonical re-render problem. Tens of websocket messages per second flow into the app; each one triggers a state update; if the whole tree subscribes to one big state blob, the whole tree re-renders, GC churns, dropped frames everywhere. The fix is not to chase memoization everywhere — it's to design the data flow so each update touches exactly the components that depend on the changed slice.
Strategies, ordered by impact:
- Slice the store by domain key. Don't keep
{ allTicks: Tick[] }. Keep{ ticks: { [symbol]: Tick } }and let each widget subscribe to its key. With Zustand, that's a selectoruseStore(s => s.ticks[symbol])— only re-renders when that one entry changes. Redux +useSelectordoes the same if you avoid creating new arrays/objects in the selector. Jotai atoms naturally give per-atom subscriptions. The win: a price update for AAPL re-renders only the AAPL card, not the 200 others.
- Stable selectors / memoized derivations. Selectors that return new arrays on every call (
s => s.items.filter(...)) defeat reference equality and force every subscriber to re-render even when no logical change happened. Wrap them withreselect'screateSelector, Zustand'suseShallow, or your own memo. A correctly memoized derived selector is the single highest-leverage change in most dashboards.
- Push subscriptions to the leaves. The websocket handler should write into the store; subscribers pull. Don't lift the live state to a top-level provider and pass it down as props — that forces the whole tree through reconciliation. Each leaf decides which slice it cares about. This also makes the leaf a self-contained "live widget" you can test in isolation.
- Throttle / coalesce upstream. For high-frequency feeds, throttle into the store at 30–60Hz, not every WS message. Use
requestAnimationFramebatching or a fixed-window aggregator. Render budget is the constraint; the user can't see 500 updates/sec anyway.
React.memofor non-trivial leaves, paired with stable callbacks (useCallback) and stable derived data. PureReact.memowithout stable refs in is worthless — props will still differ each render. Memoization is only useful when (a) the parent re-renders often, (b) the child render is non-trivial, and (c) most parent re-renders don't actually change the child's relevant props.
useDeferredValue/startTransitionfor derived views (charts, aggregates) where it's OK to lag one frame behind the latest tick. The latest value still renders to the lightweight readout; the heavy chart catches up at lower priority.
- Virtualization. Long tables of streaming rows (
react-window,@tanstack/react-virtual) — only the visible window mounts. Combined with row-level memoization, even 50k rows are cheap.
- Stable keys on list rows (
key={row.id}, not array index). React's reconciler diffs by key — wrong keys destroy and re-mount every row on each update.
useSyncExternalStorefor external stores in concurrent React. It guarantees a consistent read across the tree (no tearing) and gives you the cheapest possible subscription path.
- Move work off the render thread. Heavy aggregation, decoding, or filtering for 10k+ rows belongs in a Web Worker. The main thread should be a thin renderer of pre-computed slices.
Anti-patterns to avoid:
- Wrapping every component in
React.memo"just in case." Comparison costs CPU; closure capture inflates memory; debugging gets harder. Profile first. - Putting the live state in React Context. Context broadcasts to every consumer on every change; it's the opposite of what you want for high-frequency updates.
- Using indexes as keys. Use IDs.
- Selectors that allocate objects/arrays inline.
Profiling workflow: use the React DevTools Profiler — record a few seconds of live updates, look at the flamegraph, and find components rendering with no commit-relevant prop change. Each one is either a missing memo, a missing selector slice, or a missing stable reference. Fix the worst three and the dashboard usually stops dropping frames.
Code
Follow-up questions
- •When does React.memo hurt performance?
- •How would you profile this in production?
- •Why is shallow equality often enough?
Common mistakes
- •Storing live data in `useState` at the page level — every tick re-renders the world.
- •Selectors that return new objects every call.
- •Using `useMemo` to 'fix' renders without verifying it changes anything.
Performance considerations
- •Memoization isn't free — comparison cost + retained closures.
- •60 fps gives you a 16ms budget per frame; aim for <8ms render to leave room for everything else.
Edge cases
- •Context value identity: passing a fresh object every render forces all consumers to re-render.
- •List virtualization can interact badly with focus restoration in tables.
Real-world examples
- •Trading dashboards (price ticks), observability tools (Datadog, Honeycomb live views), Linear's real-time sync.