A page in my app locked up. Not slow — locked. The tab hit 100% CPU and the fans came on. The component involved was unremarkable, and the line responsible looked like something I have written a hundred times.
// This is an infinite render loop.
const activeItems = useStore((s) =>
Object.values(s.items).filter((i) => i.active),
);What is actually wrong
Store subscriptions decide whether to re-render by comparing the previous selector result with the new one, using reference equality by default. That works when the selector returns a value that already exists in the store.
This one builds a new array on every call. A fresh array is never reference-equal to the previous fresh array, even when the contents are identical and nothing in the store changed. So: render, selector returns a new array, comparison fails, schedule a render, repeat forever.
The insidious part is that it is not always instant. With a small store and a component that is rarely mounted, it can look like a performance smell rather than a defect, and sit there until something makes it visible.
How I found it
The mistake I made was reading the component. The component was fine. What worked was cutting the subscription out entirely, replacing it with a hardcoded array, and watching the loop stop — which told me the problem was the subscription, not anything rendered from it.
That is a habit worth having in general. When a component misbehaves, isolate its inputs before you study its output.
The fix
// Subscribe to what the store actually holds, derive locally.
const items = useStore((s) => s.items);
const activeItems = useMemo(
() => Object.values(items).filter((i) => i.active),
[items],
);Subscribe to a stable reference the store owns; do the shaping in the component where memoisation is straightforward. The alternative is passing a shallow-equality comparator to the selector, which also works — but I prefer the version where the rule is simple enough that I cannot get it wrong again.
Where it went afterwards
Straight into the repository's list of known traps, in three lines. It cost me an afternoon; it should cost the next person a paragraph. That list has turned out to be the most valuable documentation in the project, and none of it belongs in an architecture document.