Skip to content
Debugging4 min read

The render loop that ate my afternoon

A store selector that looked completely ordinary froze a page solid. The cause is a React fundamental I thought I understood.

ReactDebuggingFrontend

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.

ts
// 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

ts
// 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.

Next

What owning the whole stack actually teaches you