🪴 GoDeep
Search
← Shipping Software

Hooks and their rules

Understand what a hook actually is, why the call-order rules exist rather than being arbitrary style, and which of `useState`, `useRef`, `useMemo`, `useCallback` and `useContext` earns its place. You'll be able to recognise stale closures and read a custom hook for what it hides.

12 cards · 6 quiz questions · 10 min read

Open any agent-written React file and the top of each component is a stack of use calls. Some are load-bearing. Some are useCallback sprinkled on like salt. Occasionally one sits inside an if, and the component crashes the day a study id is missing. Hooks look like syntax but they’re plain functions with one strict requirement, and once you know what that requirement protects, most of the odd rules stop being arbitrary.

What a hook actually is

A hook is a function whose name starts with use and which taps into React features while a component renders. useState doesn’t store your state in the function — the function re-runs from scratch on every render. React keeps a record for each mounted component instance, and useState reads and writes a slot in that record.

The critical detail: the record is an ordered list, not a map of names. React has no idea your variable is called query. It knows only that the first useState call of this render gets slot 1, the second gets slot 2, and so on.

function ResponsesPanel({ studyId }: { studyId: string }) {
  const [query, setQuery] = useState("");        // slot 1
  const [page, setPage] = useState(0);           // slot 2
  const tableRef = useRef<HTMLTableElement>(null); // slot 3
  // ...
}

Render again, and React walks the same list in the same order. That only works if the calls happen in the same order every time.

The two rules, and why they aren’t style

React’s documented rules are short. Only call hooks at the top level — not inside loops, conditions, nested functions, or try/catch/finally blocks, and not after an early return. Only call hooks from React functions — a component or another hook, never a plain helper or an event handler.

Here’s what a violation does:

// Before: hook behind a condition
function ResponsesPanel({ studyId }: { studyId?: string }) {
  if (!studyId) return <Empty />;                // early return BEFORE a hook
  const [query, setQuery] = useState("");        // slot 1 — only sometimes
  const [page, setPage] = useState(0);
  // ...
}

While studyId is set, three hooks run and everything looks fine. The moment it’s empty, the component renders with zero hooks, and on the render after that React finds a different number of calls than it recorded. You get “Rendered fewer hooks than expected”, or worse, no error at all and page quietly reading query’s slot.

// After: hooks unconditionally, condition on the value
function ResponsesPanel({ studyId }: { studyId?: string }) {
  const [query, setQuery] = useState("");
  const [page, setPage] = useState(0);
  if (!studyId) return <Empty />;                // early return AFTER all hooks
  // ...
}

The same logic explains why a hook can’t go in a loop whose length changes, or inside a click handler: React is only tracking slots during a render of that component.

Which hook earns its place

HookUse it forSmell
useStateAnything the UI displays that changes over timeHolding a value you could compute during render
useRefA mutable value that must survive renders but not cause one: a timer id, a DOM node, “have we scrolled yet”Rendering ref.current, or writing to it during render
useMemoCaching a measurably slow calculation, or stabilising an object/array used as a dependencyWrapping trivial arithmetic “for performance”
useCallbackA function whose identity is observed: a hook dependency, or a prop to a React.memo childWrapping every handler in the file
useContextA genuinely app-wide value: signed-in user, theme, feature flagMoving one value down one level

useState and useRef are the pair people confuse most. The distinction is whether React needs to know.

// Before: progress in a ref, so the bar never moves
const progressRef = useRef(0);
// ... later, in a WebSocket handler
progressRef.current = msg.percent;
return <ProgressBar value={progressRef.current} />;  // React was never told

// After: displayed value in state; the socket itself in a ref
const [progress, setProgress] = useState(0);
const socketRef = useRef<WebSocket | null>(null);    // never rendered — correct as a ref

useMemo and useCallback are caches, not correctness tools. The React docs are blunt that memoising a fast calculation costs more than it saves. Before adding one, ask which comparison will notice.

// Pointless: cheap, and nothing compares the result
const total = useMemo(() => rows.length, [rows]);

// Worth it: the array identity is a dependency of the chart, which is React.memo'd
const series = useMemo(
  () => rows.map((r) => ({ x: r.submittedAt, y: r.durationSeconds })),
  [rows]
);

useContext has a cost that agents rarely mention: every consumer re-renders when the provider’s value changes by identity. A provider passing value={{ user, refresh }} creates a new object on every one of its own renders, so every consumer in the app re-renders too. Memoise the provider value, or split rarely-changing data from frequently-changing data into two contexts.

Stale closures

This is the bug that makes React feel haunted. Every render creates fresh values, and any function created during a render captures the values from that render. If the function outlives its render — inside setInterval, a subscription, a debounce, an effect with an empty dependency array — it keeps working with the old values.

// Before: badge shows 1 and freezes
useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000);  // `count` is 0 forever
  return () => clearInterval(id);
}, []);

// After: the updater form doesn't need the current value at all
useEffect(() => {
  const id = setInterval(() => setCount((c) => c + 1), 1000);
  return () => clearInterval(id);
}, []);

Three fixes, in order of preference. Use the updater form, so the current value is never needed. List the value as a dependency, so the effect tears down and re-runs when it changes. Or, when the callback needs the latest value but must not re-trigger the effect, keep it in a ref that you update each render and read ref.current inside the callback.

// Ref-as-latest-value, for a callback that must not re-subscribe
const filtersRef = useRef(filters);
filtersRef.current = filters;   // updated on every render

useEffect(() => {
  const socket = new WebSocket(url);
  socket.onmessage = (e) => applyUpdate(JSON.parse(e.data), filtersRef.current);
  return () => socket.close();
}, [url]);   // filters change without dropping the connection

Custom hooks share logic, not state

A custom hook is a function starting with use that calls other hooks. Extracting one is refactoring, not architecture:

// One place for the fetch-and-cancel dance; each caller gets its own state
export function useResponses(studyId: string) {
  const [rows, setRows] = useState<Response[]>([]);
  const [error, setError] = useState<Error | null>(null);
  useEffect(() => {
    const controller = new AbortController();
    fetch(`/api/studies/${studyId}/responses`, { signal: controller.signal })
      .then((r) => r.json())
      .then(setRows)
      .catch((e) => { if (e.name !== "AbortError") setError(e); });
    return () => controller.abort();   // cancels on studyId change and unmount
  }, [studyId]);
  return { rows, error };
}

The use prefix is not decoration. It’s how the linter — and a reader — knows the function may call hooks and must therefore obey the same rules. A helper called getFilters that calls useState inside is broken; renaming it to useFilters is the fix.

Two components calling useResponses("s-104") get two independent pieces of state and two fetches. If your codebase appears to share data through a hook, something else is doing the sharing — a context, or a query cache such as TanStack Query. Knowing which matters when you’re debugging why one panel updated and the other didn’t.

Let the linter do the work

eslint-plugin-react-hooks is the official plugin, documented on react.dev. Two rules do most of the work: rules-of-hooks catches conditional and misplaced calls, and exhaustive-deps checks that every reactive value a hook reads appears in its dependency array. Newer versions ship further lints, including ones for setting state during render or inside an effect.

The rule to internalise: an eslint-disable-next-line react-hooks/exhaustive-deps comment does not remove a dependency, it hides one. The hook still reads the value; it now reads a frozen copy. When an agent adds that comment, treat it as a marked bug, not a resolved warning.

What to check in your own code

  • Grep for eslint-disable near react-hooks — each one is a stale-value bug in waiting.
  • Check every component for a return or if (...) return that appears above a use call.
  • Grep for useRef and confirm none of the .current values appear in returned JSX.
  • Grep for setInterval, setTimeout, addEventListener and new WebSocket and check whether the callback reads state directly instead of using an updater or a ref.
  • Grep for useCallback and useMemo and delete the ones whose result nothing compares — fewer moving parts, same speed.
  • Find your context providers and check whether value is an inline object literal rather than a memoised one.
  • Confirm eslint-plugin-react-hooks is actually in package.json and that the lint step runs in CI, not only in someone’s editor.

Sources

  • React documentation — Rules of Hooks website The two rules: top level only (no loops, conditions, nested functions or try/catch/finally), and only from React functions.
  • React documentation — Reusing Logic with Custom Hooks website What a custom hook is, the `use` naming convention, and that hooks share stateful logic rather than state itself.
  • React documentation — useMemo website Caching a calculation between renders, and the guidance on when memoisation is and isn't worth it.
  • React documentation — eslint-plugin-react-hooks website The official lint rules, including rules-of-hooks and exhaustive-deps.