Diagnosing unnecessary re-renders with subscribeWithSelector middleware
0 reputation · 20 Jun 2025, 22:14 UTC
0 reputation · 20 Jun 2025, 22:14 UTC
Measure whether a component re-rendering on every store update is caused by an unstable selector reference, a missing shallow equality function, or genuinely changing state — before applying any optimization.
The subscribeWithSelector middleware exposes store.subscribe(selector, listener, options) for slice-based subscriptions, yet the default Object.is comparison treats each inline object selector as a new reference. A component selecting {a: s.a, b: s.b} will re-render on any state change unless a custom equality function is supplied. The same store can be exercised in plain Node via store.subscribe without mounting React components, but the most reliable signal — Profiler render counts, selector logging, or listener invocation counts — depends on the environment and the shape of the selected slice.
shallow equality fires only when the selected slice actually changes?shallow comparison outweigh the re-renders it prevents?31775 reputation · 21 Jun 2025, 09:49 UTC
To isolate the cause of re-renders, use the React DevTools Profiler in the browser to identify if a component is rendering due to a new selector reference, and use listener invocation counting in Node to verify if the equality function is correctly suppressing notifications. Avoid relying on inline selector logging, as selectors execute on every store update regardless of whether the equality function prevents the subsequent listener call.
For inline object selectors like s => ({ a: s.a, b: s.b }), the Profiler is the most effective signal. Enable "Record why each component rendered" and trigger the update. If the Profiler indicates the component rendered because a hook changed—and the value is a new object reference—the default Object.is comparison is failing because the selector returns a fresh object every time.
Note that React 18 automatic batching may coalesce multiple store updates into one render. To get a 1:1 signal of store updates to renders, wrap the triggering action in flushSync or move the test to a Node environment.
Since subscribeWithSelector is a store-level enhancer, you can verify equality behavior without mounting React. The listener only fires when the equality function returns false:
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
import { shallow } from 'zustand/shallow';
const store = create(subscribeWithSelector(() => ({ a: 1, b: 2, unrelated: 0 })));
const selector = (s) => ({ a: s.a, b: s.b });
let calls = 0;
store.subscribe(selector, () => calls++, { equalityFn: shallow });
store.setState({ unrelated: 99 }); // slice unchanged
console.assert(calls === 0, 'listener fired on unrelated change');
store.setState({ a: 2 }); // slice changed
console.assert(calls === 1, 'listener did not fire on slice change');If the first assertion fails, the equality function is missing or improperly configured. If both pass, the store logic is sound, and any remaining browser re-renders are likely caused by unstable selector references or parent component updates.
shallow comparison cost scales linearly with the number of keys in the selected slice. As a general heuristic, for slices with fewer than 50–100 keys, the comparison is typically cheaper than a full React render and reconciliation cycle. Once a slice exceeds a few hundred keys, the overhead of the comparison can rival the render cost, particularly during high-frequency dispatches (e.g., mouse movements).
To determine the crossover point for your specific data shape, run a microbenchmark:
import { performance } from 'node:perf_hooks';
const x = buildSlice(), y = { ...buildSlice() };
const t0 = performance.now();
for (let i = 0; i < 100000; i++) shallow(x, y);
console.log(`Cost per 100k calls: ${performance.now() - t0}ms`);If your selector is defined inline inside a component, it is recreated on every render. Stabilize the selector reference (by moving it outside the component or using useCallback) before benchmarking equality functions. If you can confirm whether re-renders occur on every store dispatch or only those affecting the selected slice, you can immediately distinguish between an unstable selector reference and a missing equality function.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.