Skip to main content

React Hooks Pitfalls That Survive Code Review (and the Cheat Sheet Habit That Catches Them)

Stale closures in useEffect, useId values that crash querySelector, useMemo that makes renders slower — four hook traps that pass review looking correct, with the actual broken code and the fix for each.

Published By Lei Li
#react #javascript #frontend #cheatsheet

React Hooks Pitfalls That Survive Code Review (and the Cheat Sheet Habit That Catches Them)

React hooks have been the default way to write components since 2019, and per Stack Overflow's 2024 Developer Survey, 39.5% of all responding developers use React — more than any other front-end framework. That popularity has a strange side effect: the most dangerous hook bugs are the ones that look idiomatic. They compile, they pass review, they even work in the happy path. Then production traffic finds the gap.

This post walks through four of those traps with the real broken code and the real fix. All four come straight out of the pitfall notes in our React Hooks Cheatsheet, which covers all 17 built-in hooks in React 18 and 19 plus ten common custom-hook patterns — each with a "when to avoid" section and the one mistake that actually bites teams.

The stale closure: useEffect that counts to 1 and stops

This is the classic, and it still ships constantly because the code reads like plain English. Here is the input — a counter that should tick up every second:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(`count is ${count}`);
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []); // <- empty deps

  return <p>{count}</p>;
}

And here is the actual output in the console:

count is 0
count is 0
count is 0
...

The displayed count jumps from 0 to 1 and then freezes. The effect ran once, so the interval callback closed over the count from the first render — forever. Every tick computes 0 + 1 and sets state to a value React already has, so no re-render happens after the first one.

The fix is the functional updater, which asks React for the current value instead of trusting the closure:

setCount(c => c + 1);

With that one change the console prints 0, 1, 2, 3… and the component counts correctly. The deeper lesson: any time a useEffect with [] deps reads state inside a callback that outlives the render, you have a stale closure candidate.

useId output is not a valid CSS selector

useId solves a real problem — stable, hydration-safe ids for accessibility wiring. But its return value is hostile to naive DOM querying, and this trips people up in tests and analytics snippets. Concretely, in React 18:

const id = useId();
console.log(id);          // ":r0:"
document.querySelector(`#${id}`);

That last line does not return null — it throws:

Uncaught DOMException: Failed to execute 'querySelector' on 'Document':
'#:r0:' is not a valid selector.

React 19 changed the prefix from :r0: to «r0» (partly so the ids can be used in CSS contexts like view-transition-name), but guillemets still need escaping in a selector string. If you genuinely must query by a useId value, either escape it or sidestep the selector parser entirely:

document.querySelector(`[id="${id}"]`);          // attribute selector, no escaping needed
document.querySelector(`#${CSS.escape(id)}`);    // or escape it properly

The intentionally awkward format is a design hint: useId is for htmlFor/aria-describedby pairs, not for getElementById-style lookups.

useMemo everywhere is a tax, not an optimization

The most common performance "fix" I see in review is wrapping every computed value in useMemo and every function in useCallback. Each of those calls has a cost: React stores the previous deps array, compares every element on every render, and keeps the cached value alive in memory. For a computation like items.filter(...) over 50 rows, the comparison machinery can cost as much as just redoing the work — and you pay it on every render, even the ones where deps changed and the memo buys nothing.

Memoization earns its keep in exactly two situations: the computation is genuinely expensive (thousands of items, layout math), or the value's referential identity matters because it feeds a React.memo child, a useEffect dependency, or a context Provider. The inline-object Provider trap is the same disease in reverse — <Ctx.Provider value={{user, theme}}> mints a fresh object every render and forces every consumer to re-render, so that object is one of the few that genuinely deserves a useMemo.

React 19's four new hooks have sharp placement rules

React 19 (stable since December 5, 2024) added useOptimistic, useActionState, useFormStatus, and the use() read primitive. The pitfall that catches nearly everyone first: useFormStatus does not see the form rendered by the same component that calls it. It reads status like context, from a parent <form> — so this returns pending: false forever:

function Form() {
  const { pending } = useFormStatus(); // always false here
  return <form action={save}><button disabled={pending}>Save</button></form>;
}

The button has to live in a child component rendered inside the form. Move the useFormStatus call into a <SubmitButton /> child and pending starts working. Similarly, useOptimistic only reconciles cleanly when the optimistic update happens inside a transition or form action — sprinkle it into a plain onClick and you get flicker instead of optimism.

My cheat sheet habit, measured

I keep a searchable reference open while reviewing React PRs, and I tested how much it actually changes my behavior over two weeks of reviews in May. Without it, I flagged hook-dependency issues only when something smelled off. With the cheat sheet open, I caught three bugs I am confident I would have approved otherwise: a useFetch custom hook with no AbortController (state update after unmount on fast navigation), a useSyncExternalStore whose getSnapshot returned a fresh object every call (infinite re-render loop under React 18 strict mode), and one more stale-closure interval like the counter above. The pattern in all three: the code was shaped correctly, and only the "common pitfall" note for that specific hook made the bug visible.

If your stack pairs React with TypeScript — which is most stacks now — the same reference-while-reviewing habit works for type-level traps; we keep a TypeScript Cheatsheet for exactly that. And if you are migrating legacy markup into components and want the JSX attribute renames (classclassName, forhtmlFor) handled mechanically, the HTML to JSX converter does the tedious part so review attention stays on the hooks.

Hooks reward exactly one habit: distrust code that looks familiar. The four traps above all pass the "reads fine" test. Keep the pitfalls list closer than the API docs.


Made by Toolora · Updated 2026-06-12