A store with a selector lets each subscriber name the slice of state it cares about, so the store can wake it only when that slice changes. createGlobalState built the wire that lets React hear about a store at all. It held one value, and every subscriber wanted it. This question starts where that one stopped.
Once the store holds a whole app — a user, a cart, a draft — waking everyone is a performance bug you cannot see. A header reading state.user.name re-renders on every keystroke into state.draft, because the store rings every bell on every change. Nothing throws. Nothing looks broken. It is just slower than it looks, and this is why every real store ships a selector and an equality check.
Build createStore(initialState). The vanilla half is written for you; useSelector is the exercise.
function createStore<S>(initialState: S): {
getState(): S;
setState(patch: Partial<S> | ((prev: S) => Partial<S>)): void;
subscribe(listener: () => void): () => void; // returns unsubscribe
useSelector<T>(
selector: (state: S) => T,
isEqual?: (a: T, b: T) => boolean, // defaults to Object.is
): T;
};
Two siblings read two different slices of one store:
const store = createStore({ user: { name: 'ada' }, cart: { count: 0 } });
function Header() {
const name = store.useSelector((s) => s.user.name);
return <h1>{name}</h1>;
}
function CartBadge() {
const count = store.useSelector((s) => s.cart.count);
return <span>{count}</span>;
}
store.setState({ cart: { count: 3 } });
// CartBadge re-renders. Header does not — its answer did not move.
The second argument is for selectors that build their answer:
// filter() returns a new array every call, so this value is never Object.is
// equal to its own last answer — it re-renders forever.
store.useSelector((s) => s.items.filter((i) => i.done));
// Tell the store what unchanged means for this value, and it settles.
store.useSelector((s) => s.items.filter((i) => i.done), shallowEqual);
getState, setState and subscribe are already written — they are the store from Observable Store and Mini Redux. Only useSelector is yours.setState merges. setState({ a: 1 }) leaves the other keys untouched, and the updater form (prev) => partial computes the patch from the current state. It builds a new state object every time; it never mutates.Object.is. It is what React itself compares snapshots with. The second argument replaces it — see Object.is on MDN.useSelector((s) => s.a) is a brand-new function on every render. That must not resubscribe, churn, or loop.shallowEqual. The tests pass one in.You will turn a subscription from a doorbell into a question — each component says what it cares about, and the store decides who actually needs waking.
createGlobalState answered how React hears about a store at all: you hand React a way to read the value and a way to subscribe, and it re-renders. That store held one value, so the answer to who should re-render was always everybody, and that was correct.
Now put a whole app in it — { user, cart, draft } — and everybody is a bug. Your header reads state.user.name. Somebody types one character into state.draft. The header re-renders, and so does the cart, and so does every other subscriber, because the store has no idea what any of them wanted. Nothing throws. Nothing is stale. The app just does a pile of work nobody asked for, and you will not find it by reading the code — you will find it in a profiler, six months later, on a page that got slow for no reason.
The fix is one sentence: each subscriber says what it cares about, and the store compares that subscriber's answer, before and after, to decide whether to wake it.
A subscription is a question, not a doorbell. A doorbell rings for everyone in the building. A question has your answer, and the only thing worth waking you for is your answer changing.
Notice what the selector version does not do: it does not send the new state to anyone. It asks each subscriber its own question again and compares the answer with the answer that subscriber last rendered. state.cart moved, so CartBadge wakes. state.user.name did not, so Header does not — even though the state object it lives in is brand new.
So you bolt a selector onto the store from the last question. Every subscriber keeps a throwaway useState to force a re-render, and the selector picks a slice out during render:
function useSelector(selector) {
const [, forceRender] = useState(0);
useEffect(() => subscribe(() => forceRender((n) => n + 1)), []);
return selector(state);
}
Read it again, because it looks finished. It returns the right slice. It updates when the store updates. Measured against this question's suite it passes twelve of eighteen tests — nested paths, falsy values, unmounting, two stores side by side, all green.
And it has not done the thing. forceRender((n) => n + 1) produces a new number for every subscriber on every write, so every subscriber re-renders every time, exactly as before. The selector runs after the decision to re-render has already been made. It reads, but it does not gate — and the six it fails are exactly the six that care when a component re-renders.
That is the shape of this whole bug in one line: adding a selector changes nothing on its own. The selector is not the feature. The comparison is.
const { useRef, useSyncExternalStore } = require('react');
// A selector can legitimately select `undefined` (a key that is not there yet),
// so `undefined` cannot double as "no cached value". A private symbol can never
// collide with anything a selector returns.
const EMPTY = Symbol('empty');
function createStore(initialState) {
// The vanilla store. No React in it, and none of it is new: this is the
// getState/setState/subscribe core from Observable Store and Mini Redux.
let state = initialState;
const listeners = new Set();
const getState = () => state;
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const setState = (patch) => {
const partial = typeof patch === 'function' ? patch(state) : patch;
// Merge, so a writer touching one key does not have to know the other keys
// exist. Build a NEW object — never mutate — so a selector reading a slice
// can tell changed from unchanged by reference.
state = { ...state, ...partial };
// Copy before iterating: a listener that unsubscribes mid-round must not
// disturb the loop.
for (const listener of [...listeners]) listener();
};
function useSelector(selector, isEqual = Object.is) {
// The last value THIS component selected. One cache per component, because
// every component selects something different.
const cache = useRef(EMPTY);
const getSnapshot = () => {
const next = selector(getState());
const prev = cache.current;
// The whole question is this line. React re-renders when the snapshot
// changes by Object.is, so "do not re-render" has exactly one spelling:
// hand back the PREVIOUS reference. isEqual does not tell React anything
// — it decides whether we are allowed to return the old value again.
if (prev !== EMPTY && isEqual(prev, next)) return prev;
cache.current = next;
return next;
};
// subscribe is stable and getSnapshot is cheap to rebuild; React re-reads it
// every render. The third argument is the server snapshot — see
// createGlobalState for why omitting it throws under SSR.
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
return { getState, setState, subscribe, useSelector };
}
module.exports = { createStore };
The shift from the first attempt is that the improvised version pushed a re-render at React, while this one hands React a value and lets React decide. getSnapshot no longer returns the state — it returns this component's answer. React already compares snapshots with Object.is and bails when they match, so for a selector returning a primitive, selectivity is free: Header asking for state.user.name gets 'ada' back after the write, React compares it with the 'ada' it rendered last time, and does nothing.
That is worth saying plainly, because it is the part people expect to be hard: you do not write the gate. React is the gate. Your job is to give it the right thing to compare.
const store = createStore({ user: { name: 'ada' }, cart: { count: 0 } }), with a Header selecting s.user.name and a CartBadge selecting s.cart.count.
useSyncExternalStore calls subscribe(onStoreChange) — listeners now holds React's callback for Header — and calls getSnapshot(). The selector returns 'ada'; cache.current is EMPTY, so we store 'ada' and return it.subscribe, a different onStoreChange. Its selector returns 0, which is cached in its own cache ref. Two components, two caches, one store.store.setState({ cart: { count: 3 } }). The merge builds a brand-new state object, { user, cart }, where cart is the patch and user is the identical object it always was, carried across by reference. The slice did not move; the thing holding it did.onStoreChange means only you are out of date, and it is lying to one of them.getSnapshot() runs selector(newState) → newState.user.name → 'ada'. Object.is('ada', 'ada') is true, so we return cache.current — the same value React already has. React compares, finds no change, and Header never runs. The lie cost one property read.0 becomes 3. Object.is(0, 3) is false, so we cache 3 and return it. React sees a new snapshot and re-renders. The badge shows 3.store.setState({ cart: { count: 3 } }) again. The merge allocates another new state object, so by reference the state absolutely changed. Both listeners fire. Header: 'ada', equal, stays put. CartBadge: 3, Object.is(3, 3) is true, returns the cached 3 — and nothing re-renders at all. The state object changed and nobody moved, which is exactly why setState needs no guard of its own. The gate is per-subscriber, and it is the only gate you need.Step 5 and step 6 are the honest cost: they run for every subscriber on every write. That is O(subscribers) selector calls plus O(subscribers) equality checks per setState — cheap, but not free, and it is the reason a selector must be a fast read and an equality check must be a fast compare. You are trading a little work on every write against a lot of work on the writes that did not concern you.
getSnapshot returning selector(state) has one sharp edge, and it is not a corner case — it is the second thing everybody writes.
// Every call builds a new array. Object.is says it changed. Every time.
store.useSelector((s) => s.items.filter((i) => i.done));
filter is not doing anything wrong; it returns a new array because that is what filter does. But Object.is(oldArray, newArray) compares references, and two arrays with identical contents are two different objects. So React re-renders, calls getSnapshot again, gets a third new array, re-renders again. Measured, this does not degrade — it crashes, at 55 renders, with React's own diagnosis:
The result of getSnapshot should be cached to avoid an infinite loop
Uncaught Error: Maximum update depth exceeded.
This is the exact trap createGlobalState warned about and never had to face, because a store holding one value returns it directly and a directly-returned value is already cached. The moment the snapshot is computed, caching it stops being free — and that is what the second argument buys:
store.useSelector((s) => s.items.filter((i) => i.done), shallowEqual);
Now trace what isEqual actually does in the code above, because it is not what the name suggests. It does not tell React to skip a render — there is no such API. It decides whether getSnapshot is allowed to return prev, the array from last time. The new array is thrown away and the old reference is handed back, so React's own Object.is finds no change and bails.
There is exactly one comparison in React: Object.is on the snapshot. Every equality function in every store library is a device for winning that one comparison. That reframing is the thing to take away from this question, because it explains the API you are about to meet in every library — and it explains why the other fix works too.
Two things sit in this pipeline that both compare things and both hand back old references, and they get conflated constantly.
isEqual decides whether this component re-renders. It compares two selected values and runs once per subscriber per write. It does not make the filtering cheaper — the filter still runs on every write, for every subscriber.createSelector decides whether the value is recomputed. It compares the input slices and runs once per selector call. It does not know React exists and cannot re-render anything.They are different jobs, and you often want both. But here is the part that surprises people: a memoised selector also fixes the infinite loop, and by the same mechanism. createSelector returns the cached array when state.items has not changed by reference — the same array, the same reference — so Object.is passes and no equality function is needed. Two different answers to one crash, because both end in a stable reference. The difference is which comparison you pay for: isEqual walks the output on every write, createSelector compares the inputs and skips the walk entirely.
Both mainstream answers are on the shelf, and they disagree — which is more interesting than either one alone.
| react-redux 9.3.0 | zustand 5.0.14 | this store | |
|---|---|---|---|
| built on | useSyncExternalStoreWithSelector | useSyncExternalStore | useSyncExternalStore |
| default equality | (a, b) => a === b | Object.is (React's own) | Object.is |
| equality argument | 2nd arg, or an options object | none | 2nd arg |
| fresh-object selector | warns, re-renders on every action | crashes | crashes |
| the escape hatch | shallowEqual as the 2nd arg | useShallow(selector) | isEqual as the 2nd arg |
zustand 5 dropped the equality argument entirely. Its whole useStore is one useSyncExternalStore call whose getSnapshot is () => selector(api.getState()) — the naive shape, shipped on purpose, with no equality parameter to reach for. Measured against zustand 5.0.14, a fresh-object selector produces the same 55 renders and the same Maximum update depth exceeded as the code above. That is not a criticism — it is a deliberate trade, and the replacement is useShallow, which is worth reading because it is not an equality function at all:
// zustand/react/shallow.js, verbatim
function useShallow(selector) {
const prev = useRef(undefined);
return (state) => {
const next = selector(state);
return shallow(prev.current, next) ? prev.current : (prev.current = next);
};
}
That is a selector wrapper that caches the last answer and returns the old reference when the new one is shallow-equal. It is the reselect move, not the equality move — zustand solved the identity problem by making the selector return a stable reference, which is the same fix, moved one box to the left in the diagram above.
react-redux takes the other route and does not crash. It uses useSyncExternalStoreWithSelector, which keeps a second cache keyed on the raw state reference: if the state has not changed, it returns the last selection without even calling your selector. That is what downgrades zustand's crash into a mere re-render on every action — measured, plus a dev warning telling you to memoise.
One honest wart, because it is a nice illustration of why the default matters. react-redux's default equality is refEquality = (a, b) => a === b, not Object.is. The two differ on exactly two inputs, and NaN is one of them: NaN === NaN is false while Object.is(NaN, NaN) is true. So a selector returning NaN — a perfectly stable value — trips react-redux's dev-mode stability check, which calls your selector twice and warns that it returned a different result when called with the same parameters. It printed selected: NaN, selected2: NaN and complained they differ. The render count is still correct, because React's own Object.is rescues the final comparison; the only casualty is a false warning sending you to memoise a selector that was already fine. Object.is is the better default, and it is the one React uses.
useSelector that returns selector(state) while the store still pokes a useState per subscriber reads the right value and gates nothing — every subscriber still re-renders on every write. Fix: the selected value must be what React compares, which means it must be the snapshot.(s) => s.items.filter(...), (s) => ({ name: s.name }), (s) => s.items.map(...) all return a new reference every call, are never Object.is-equal to themselves, and crash with Maximum update depth exceeded. Fix: pass shallowEqual as the second argument, or memoise the selector so it returns a stable reference. Selecting two values with one object literal is the usual way in — select them with two useSelector calls instead, and the problem evaporates.undefined as the empty-cache marker. useRef() starts at undefined, and (s) => s.user.avatar legitimately selects undefined, so a prev === undefined check treats a real answer as no answer yet and re-caches forever. Fix: a private Symbol, which nothing else can ever equal.deepEqual over a thousand-item list, ×50 subscribers, on every keystroke costs more than the re-renders it prevents. Fix: keep the selector a read and the check shallow; if the derivation is expensive, memoise it rather than comparing harder.useSelector((s) => s) gives back a new object on every write (the merge always allocates), so it re-renders on everything and you have rebuilt the doorbell with extra steps. react-redux ships a dev check specifically for this one.state.cart.count = 3 keeps every reference identical, so every selector answers exactly what it answered last time and nothing re-renders. Fix: build a new object; the gate is reference-based and mutation makes it blind.setState(patch) for dispatch(action) and a pure reducer and you have Mini Redux with a React binding on top — which is, near enough, what React-Redux is. Its useSelector attaches to exactly the subscribe primitive that question builds.createSelector caches the derivation so the selector hands back a stable reference, which fixes the loop without an equality function. Pair it with this hook and the equality argument mostly stops being necessary.setState calls in one event handler currently run two full notify rounds. React 18 auto-batches the resulting renders, but not your selector calls. Real stores queue notifications in a microtask and flush once.useSyncExternalStoreWithSelector, and it is what makes react-redux churn where this store crashes.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A store with a selector lets each subscriber name the slice of state it cares about, so the store can wake it only when that slice changes. createGlobalState built the wire that lets React hear about a store at all. It held one value, and every subscriber wanted it. This question starts where that one stopped.
Once the store holds a whole app — a user, a cart, a draft — waking everyone is a performance bug you cannot see. A header reading state.user.name re-renders on every keystroke into state.draft, because the store rings every bell on every change. Nothing throws. Nothing looks broken. It is just slower than it looks, and this is why every real store ships a selector and an equality check.
Build createStore(initialState). The vanilla half is written for you; useSelector is the exercise.
function createStore<S>(initialState: S): {
getState(): S;
setState(patch: Partial<S> | ((prev: S) => Partial<S>)): void;
subscribe(listener: () => void): () => void; // returns unsubscribe
useSelector<T>(
selector: (state: S) => T,
isEqual?: (a: T, b: T) => boolean, // defaults to Object.is
): T;
};
Two siblings read two different slices of one store:
const store = createStore({ user: { name: 'ada' }, cart: { count: 0 } });
function Header() {
const name = store.useSelector((s) => s.user.name);
return <h1>{name}</h1>;
}
function CartBadge() {
const count = store.useSelector((s) => s.cart.count);
return <span>{count}</span>;
}
store.setState({ cart: { count: 3 } });
// CartBadge re-renders. Header does not — its answer did not move.
The second argument is for selectors that build their answer:
// filter() returns a new array every call, so this value is never Object.is
// equal to its own last answer — it re-renders forever.
store.useSelector((s) => s.items.filter((i) => i.done));
// Tell the store what unchanged means for this value, and it settles.
store.useSelector((s) => s.items.filter((i) => i.done), shallowEqual);
getState, setState and subscribe are already written — they are the store from Observable Store and Mini Redux. Only useSelector is yours.setState merges. setState({ a: 1 }) leaves the other keys untouched, and the updater form (prev) => partial computes the patch from the current state. It builds a new state object every time; it never mutates.Object.is. It is what React itself compares snapshots with. The second argument replaces it — see Object.is on MDN.useSelector((s) => s.a) is a brand-new function on every render. That must not resubscribe, churn, or loop.shallowEqual. The tests pass one in.You will turn a subscription from a doorbell into a question — each component says what it cares about, and the store decides who actually needs waking.
createGlobalState answered how React hears about a store at all: you hand React a way to read the value and a way to subscribe, and it re-renders. That store held one value, so the answer to who should re-render was always everybody, and that was correct.
Now put a whole app in it — { user, cart, draft } — and everybody is a bug. Your header reads state.user.name. Somebody types one character into state.draft. The header re-renders, and so does the cart, and so does every other subscriber, because the store has no idea what any of them wanted. Nothing throws. Nothing is stale. The app just does a pile of work nobody asked for, and you will not find it by reading the code — you will find it in a profiler, six months later, on a page that got slow for no reason.
The fix is one sentence: each subscriber says what it cares about, and the store compares that subscriber's answer, before and after, to decide whether to wake it.
A subscription is a question, not a doorbell. A doorbell rings for everyone in the building. A question has your answer, and the only thing worth waking you for is your answer changing.
Notice what the selector version does not do: it does not send the new state to anyone. It asks each subscriber its own question again and compares the answer with the answer that subscriber last rendered. state.cart moved, so CartBadge wakes. state.user.name did not, so Header does not — even though the state object it lives in is brand new.
So you bolt a selector onto the store from the last question. Every subscriber keeps a throwaway useState to force a re-render, and the selector picks a slice out during render:
function useSelector(selector) {
const [, forceRender] = useState(0);
useEffect(() => subscribe(() => forceRender((n) => n + 1)), []);
return selector(state);
}
Read it again, because it looks finished. It returns the right slice. It updates when the store updates. Measured against this question's suite it passes twelve of eighteen tests — nested paths, falsy values, unmounting, two stores side by side, all green.
And it has not done the thing. forceRender((n) => n + 1) produces a new number for every subscriber on every write, so every subscriber re-renders every time, exactly as before. The selector runs after the decision to re-render has already been made. It reads, but it does not gate — and the six it fails are exactly the six that care when a component re-renders.
That is the shape of this whole bug in one line: adding a selector changes nothing on its own. The selector is not the feature. The comparison is.
const { useRef, useSyncExternalStore } = require('react');
// A selector can legitimately select `undefined` (a key that is not there yet),
// so `undefined` cannot double as "no cached value". A private symbol can never
// collide with anything a selector returns.
const EMPTY = Symbol('empty');
function createStore(initialState) {
// The vanilla store. No React in it, and none of it is new: this is the
// getState/setState/subscribe core from Observable Store and Mini Redux.
let state = initialState;
const listeners = new Set();
const getState = () => state;
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const setState = (patch) => {
const partial = typeof patch === 'function' ? patch(state) : patch;
// Merge, so a writer touching one key does not have to know the other keys
// exist. Build a NEW object — never mutate — so a selector reading a slice
// can tell changed from unchanged by reference.
state = { ...state, ...partial };
// Copy before iterating: a listener that unsubscribes mid-round must not
// disturb the loop.
for (const listener of [...listeners]) listener();
};
function useSelector(selector, isEqual = Object.is) {
// The last value THIS component selected. One cache per component, because
// every component selects something different.
const cache = useRef(EMPTY);
const getSnapshot = () => {
const next = selector(getState());
const prev = cache.current;
// The whole question is this line. React re-renders when the snapshot
// changes by Object.is, so "do not re-render" has exactly one spelling:
// hand back the PREVIOUS reference. isEqual does not tell React anything
// — it decides whether we are allowed to return the old value again.
if (prev !== EMPTY && isEqual(prev, next)) return prev;
cache.current = next;
return next;
};
// subscribe is stable and getSnapshot is cheap to rebuild; React re-reads it
// every render. The third argument is the server snapshot — see
// createGlobalState for why omitting it throws under SSR.
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
return { getState, setState, subscribe, useSelector };
}
module.exports = { createStore };
The shift from the first attempt is that the improvised version pushed a re-render at React, while this one hands React a value and lets React decide. getSnapshot no longer returns the state — it returns this component's answer. React already compares snapshots with Object.is and bails when they match, so for a selector returning a primitive, selectivity is free: Header asking for state.user.name gets 'ada' back after the write, React compares it with the 'ada' it rendered last time, and does nothing.
That is worth saying plainly, because it is the part people expect to be hard: you do not write the gate. React is the gate. Your job is to give it the right thing to compare.
const store = createStore({ user: { name: 'ada' }, cart: { count: 0 } }), with a Header selecting s.user.name and a CartBadge selecting s.cart.count.
useSyncExternalStore calls subscribe(onStoreChange) — listeners now holds React's callback for Header — and calls getSnapshot(). The selector returns 'ada'; cache.current is EMPTY, so we store 'ada' and return it.subscribe, a different onStoreChange. Its selector returns 0, which is cached in its own cache ref. Two components, two caches, one store.store.setState({ cart: { count: 3 } }). The merge builds a brand-new state object, { user, cart }, where cart is the patch and user is the identical object it always was, carried across by reference. The slice did not move; the thing holding it did.onStoreChange means only you are out of date, and it is lying to one of them.getSnapshot() runs selector(newState) → newState.user.name → 'ada'. Object.is('ada', 'ada') is true, so we return cache.current — the same value React already has. React compares, finds no change, and Header never runs. The lie cost one property read.0 becomes 3. Object.is(0, 3) is false, so we cache 3 and return it. React sees a new snapshot and re-renders. The badge shows 3.store.setState({ cart: { count: 3 } }) again. The merge allocates another new state object, so by reference the state absolutely changed. Both listeners fire. Header: 'ada', equal, stays put. CartBadge: 3, Object.is(3, 3) is true, returns the cached 3 — and nothing re-renders at all. The state object changed and nobody moved, which is exactly why setState needs no guard of its own. The gate is per-subscriber, and it is the only gate you need.Step 5 and step 6 are the honest cost: they run for every subscriber on every write. That is O(subscribers) selector calls plus O(subscribers) equality checks per setState — cheap, but not free, and it is the reason a selector must be a fast read and an equality check must be a fast compare. You are trading a little work on every write against a lot of work on the writes that did not concern you.
getSnapshot returning selector(state) has one sharp edge, and it is not a corner case — it is the second thing everybody writes.
// Every call builds a new array. Object.is says it changed. Every time.
store.useSelector((s) => s.items.filter((i) => i.done));
filter is not doing anything wrong; it returns a new array because that is what filter does. But Object.is(oldArray, newArray) compares references, and two arrays with identical contents are two different objects. So React re-renders, calls getSnapshot again, gets a third new array, re-renders again. Measured, this does not degrade — it crashes, at 55 renders, with React's own diagnosis:
The result of getSnapshot should be cached to avoid an infinite loop
Uncaught Error: Maximum update depth exceeded.
This is the exact trap createGlobalState warned about and never had to face, because a store holding one value returns it directly and a directly-returned value is already cached. The moment the snapshot is computed, caching it stops being free — and that is what the second argument buys:
store.useSelector((s) => s.items.filter((i) => i.done), shallowEqual);
Now trace what isEqual actually does in the code above, because it is not what the name suggests. It does not tell React to skip a render — there is no such API. It decides whether getSnapshot is allowed to return prev, the array from last time. The new array is thrown away and the old reference is handed back, so React's own Object.is finds no change and bails.
There is exactly one comparison in React: Object.is on the snapshot. Every equality function in every store library is a device for winning that one comparison. That reframing is the thing to take away from this question, because it explains the API you are about to meet in every library — and it explains why the other fix works too.
Two things sit in this pipeline that both compare things and both hand back old references, and they get conflated constantly.
isEqual decides whether this component re-renders. It compares two selected values and runs once per subscriber per write. It does not make the filtering cheaper — the filter still runs on every write, for every subscriber.createSelector decides whether the value is recomputed. It compares the input slices and runs once per selector call. It does not know React exists and cannot re-render anything.They are different jobs, and you often want both. But here is the part that surprises people: a memoised selector also fixes the infinite loop, and by the same mechanism. createSelector returns the cached array when state.items has not changed by reference — the same array, the same reference — so Object.is passes and no equality function is needed. Two different answers to one crash, because both end in a stable reference. The difference is which comparison you pay for: isEqual walks the output on every write, createSelector compares the inputs and skips the walk entirely.
Both mainstream answers are on the shelf, and they disagree — which is more interesting than either one alone.
| react-redux 9.3.0 | zustand 5.0.14 | this store | |
|---|---|---|---|
| built on | useSyncExternalStoreWithSelector | useSyncExternalStore | useSyncExternalStore |
| default equality | (a, b) => a === b | Object.is (React's own) | Object.is |
| equality argument | 2nd arg, or an options object | none | 2nd arg |
| fresh-object selector | warns, re-renders on every action | crashes | crashes |
| the escape hatch | shallowEqual as the 2nd arg | useShallow(selector) | isEqual as the 2nd arg |
zustand 5 dropped the equality argument entirely. Its whole useStore is one useSyncExternalStore call whose getSnapshot is () => selector(api.getState()) — the naive shape, shipped on purpose, with no equality parameter to reach for. Measured against zustand 5.0.14, a fresh-object selector produces the same 55 renders and the same Maximum update depth exceeded as the code above. That is not a criticism — it is a deliberate trade, and the replacement is useShallow, which is worth reading because it is not an equality function at all:
// zustand/react/shallow.js, verbatim
function useShallow(selector) {
const prev = useRef(undefined);
return (state) => {
const next = selector(state);
return shallow(prev.current, next) ? prev.current : (prev.current = next);
};
}
That is a selector wrapper that caches the last answer and returns the old reference when the new one is shallow-equal. It is the reselect move, not the equality move — zustand solved the identity problem by making the selector return a stable reference, which is the same fix, moved one box to the left in the diagram above.
react-redux takes the other route and does not crash. It uses useSyncExternalStoreWithSelector, which keeps a second cache keyed on the raw state reference: if the state has not changed, it returns the last selection without even calling your selector. That is what downgrades zustand's crash into a mere re-render on every action — measured, plus a dev warning telling you to memoise.
One honest wart, because it is a nice illustration of why the default matters. react-redux's default equality is refEquality = (a, b) => a === b, not Object.is. The two differ on exactly two inputs, and NaN is one of them: NaN === NaN is false while Object.is(NaN, NaN) is true. So a selector returning NaN — a perfectly stable value — trips react-redux's dev-mode stability check, which calls your selector twice and warns that it returned a different result when called with the same parameters. It printed selected: NaN, selected2: NaN and complained they differ. The render count is still correct, because React's own Object.is rescues the final comparison; the only casualty is a false warning sending you to memoise a selector that was already fine. Object.is is the better default, and it is the one React uses.
useSelector that returns selector(state) while the store still pokes a useState per subscriber reads the right value and gates nothing — every subscriber still re-renders on every write. Fix: the selected value must be what React compares, which means it must be the snapshot.(s) => s.items.filter(...), (s) => ({ name: s.name }), (s) => s.items.map(...) all return a new reference every call, are never Object.is-equal to themselves, and crash with Maximum update depth exceeded. Fix: pass shallowEqual as the second argument, or memoise the selector so it returns a stable reference. Selecting two values with one object literal is the usual way in — select them with two useSelector calls instead, and the problem evaporates.undefined as the empty-cache marker. useRef() starts at undefined, and (s) => s.user.avatar legitimately selects undefined, so a prev === undefined check treats a real answer as no answer yet and re-caches forever. Fix: a private Symbol, which nothing else can ever equal.deepEqual over a thousand-item list, ×50 subscribers, on every keystroke costs more than the re-renders it prevents. Fix: keep the selector a read and the check shallow; if the derivation is expensive, memoise it rather than comparing harder.useSelector((s) => s) gives back a new object on every write (the merge always allocates), so it re-renders on everything and you have rebuilt the doorbell with extra steps. react-redux ships a dev check specifically for this one.state.cart.count = 3 keeps every reference identical, so every selector answers exactly what it answered last time and nothing re-renders. Fix: build a new object; the gate is reference-based and mutation makes it blind.setState(patch) for dispatch(action) and a pure reducer and you have Mini Redux with a React binding on top — which is, near enough, what React-Redux is. Its useSelector attaches to exactly the subscribe primitive that question builds.createSelector caches the derivation so the selector hands back a stable reference, which fixes the loop without an equality function. Pair it with this hook and the equality argument mostly stops being necessary.setState calls in one event handler currently run two full notify rounds. React 18 auto-batches the resulting renders, but not your selector calls. Real stores queue notifications in a microtask and flush once.useSyncExternalStoreWithSelector, and it is what makes react-redux churn where this store crashes.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.