Appearance
6.4.2 — Hooks
A counter that increments once a second. It renders 0, then 1, and then stops forever.
tsx
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []);
return <p>{count}</p>;
}The interval keeps firing. setCount keeps being called. And it keeps setting the value to 1, because the count captured inside that callback is the count from the render where the effect ran — which was 0, forever.
This is a closure (Chapter 3.6.2) doing exactly what closures do, meeting a rendering model where each render has its own separate copy of every value. Once you see that second half clearly, most React confusion resolves at once. So this page builds it from the mechanism up.
1. How a function remembers anything
A component is a plain function. It has no this, no instance, nothing that survives between calls. Yet useState returns the value you set last time. Where does it live?
On the fiber. Chapter 6.4.1 described the fiber as React's record of a mounted component. Each fiber holds a linked list of hook records, and React walks that list in order as your function calls hooks:
fiber.memoizedState → { state: 0, next } → ← useState(0)
{ deps: [], next } → ← useEffect(…, [])
{ current: null, next: null } ← useRef(null)There are no names in that list. React does not know that the first entry is count; it knows only that it is the first hook called during this render. On the next render your function runs again, React resets a pointer to the head of the list, and each hook call takes the next record in sequence.
Every rule of hooks falls straight out of this, and none of them is arbitrary:
Do not call hooks conditionally. If a hook is skipped on one render, every subsequent hook shifts by one position and reads the wrong record. useState for a name would read the record belonging to useEffect. React usually catches this and throws "rendered fewer hooks than expected", but the underlying failure is a mismatched list, not a style violation.
Do not call hooks in loops whose length can change, for the same reason.
Only call hooks from components or from other hooks. Outside a render there is no fiber to attach to.
The naming convention useSomething is what makes the linter work. The rule cannot analyse an arbitrary function call, so it relies on the prefix to know that a function may contain hooks and must therefore follow the same rules.
2. State is a snapshot, not a variable
This is the mental model shift that everything else rests on.
tsx
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
console.log(count); // prints the OLD value — 0 on the first click
}
return <button onClick={handleClick}>{count}</button>;
}Three calls, and the count goes up by one. The log prints the value from before the click.
count is a const inside this call of the function. It cannot change. setCount(count + 1) is setCount(0 + 1) three times, and the last one wins. Only the next render calls the function again with a new count.
Say it as a rule: a render is a snapshot. Props, state, event handlers and everything closed over belong to that one render and never change. Setting state does not modify a variable; it schedules a new render, in which the function runs again with new values.
The functional updater is the escape hatch, and it is the fix whenever the new value depends on the previous one:
tsx
setCount(c => c + 1); // three of these → +3React applies the queued functions in order against the latest value, so it does not matter what the render's snapshot said.
Two more useState details:
tsx
// Lazy initialisation: the function runs ONCE, on mount.
const [rows, setRows] = useState(() => parseHugeCsv(raw)); // (1)
// Wrong: parseHugeCsv runs on EVERY render, and its result is thrown away.
const [rows2, setRows2] = useState(parseHugeCsv(raw)); // (2)Line (1) passes a function, which React calls only on the first render. Line (2) calls it immediately, every time — the value is ignored after mount, but the work is done anyway.
And bailout by identity: if you set state to a value that is Object.is-equal to the current one, React skips the re-render. Which is why setItems(items) after a mutation does nothing — the array is the same object. State must be replaced, not mutated.
3. useEffect is synchronisation, not a lifecycle
The most common misreading of useEffect is "code that runs after render, and the array says when". A more useful reading, and the one that makes the dependency array obvious:
An effect synchronises something outside React with the current render's values. The dependency array is not a schedule — it is the list of values from this render that the effect uses.
Under that reading, the array is not a choice. It is determined by the effect body: if you use a value, it goes in the array. The linter is not being pedantic; it is deriving a fact.
tsx
useEffect(() => {
const socket = openSocket(roomId); // (1) uses roomId
socket.on('message', onMessage); // (2) uses onMessage
return () => socket.close(); // (3) cleanup
}, [roomId, onMessage]); // (4) exactly what was usedLine (3) is the part that is most often skipped and most often needed. React runs the cleanup before the next run of the effect, and again on unmount. So when roomId changes, the sequence is: close the old socket, then open the new one. You are not writing "on unmount" logic; you are writing "undo this particular synchronisation" logic.
The stale closure, and its three fixes
Back to the opening bug. The effect ran once with count === 0, and the interval callback closed over that render's count. Three ways out, in the order you should reach for them:
tsx
// (1) Functional update — the effect no longer needs count at all.
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []); // honest empty array
// (2) Include the dependency — correct, but tears down and recreates every second.
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, [count]);
// (3) A ref holding the latest callback — for when the value genuinely
// cannot be expressed as a functional update.
const latest = useRef(onTick);
useEffect(() => { latest.current = onTick; });
useEffect(() => {
const id = setInterval(() => latest.current(), 1000);
return () => clearInterval(id);
}, []);Option (1) is best because it removes the dependency honestly rather than hiding it. This is the discipline that matters: never delete a dependency to silence the linter. An empty array that lies is how you get bugs that appear only on the second interaction. Restructure so the dependency genuinely is not needed.
The fetch race, which every application has
tsx
useEffect(() => {
let cancelled = false; // (1)
const controller = new AbortController();
(async () => {
try {
const res = await fetch(`/api/users/${userId}`, { signal: controller.signal });
if (!res.ok) throw new ApiError(res.status);
const data = await res.json();
if (!cancelled) setUser(data); // (2)
} catch (err) {
if (!cancelled && (err as Error).name !== 'AbortError') setError(err);
}
})();
return () => { cancelled = true; controller.abort(); }; // (3)
}, [userId]);Without lines (1)–(3): the user clicks user 1, then quickly user 2. Two requests are in flight. If user 1's response arrives second — entirely possible, and more likely on a slow connection — it overwrites user 2's data and the screen shows the wrong person with no error anywhere.
Line (3) both aborts the request and flips the flag. Line (2) then refuses to apply a response from a superseded render. Every hand-written data fetch needs this, which is a large part of why data-fetching libraries exist — Chapter 6.4.4 covers what they do beyond this.
When not to use an effect
A great deal of unnecessary complexity comes from effects that should not exist. Three cases:
Derived state. If a value can be computed from existing props or state, compute it during render:
tsx
// Wrong — an extra render, and a moment where the two disagree.
const [total, setTotal] = useState(0);
useEffect(() => { setTotal(items.reduce(sum, 0)); }, [items]);
// Right — one render, impossible to desynchronise.
const total = items.reduce(sum, 0); Responding to an event. If something should happen because the user did something, it belongs in the event handler, not in an effect watching for the state change. Sending an analytics event or showing a toast from an effect makes it fire again whenever that state is reached by another route.
Resetting state when a prop changes. Use a key (Chapter 6.4.1) to remount instead.
The test that separates the two: an effect is for synchronising with something outside React — the DOM, a socket, a subscription, the document title, a third-party widget, a timer. If both sides of the interaction are inside React, an effect is probably the wrong tool.
useLayoutEffect
Same signature, different timing: it runs after the DOM is updated but before the browser paints (Chapter 6.4.1's commit diagram). Its use is measure-then-adjust, where letting the user see the intermediate state would be a visible flicker:
tsx
useLayoutEffect(() => {
const { height } = tooltipRef.current!.getBoundingClientRect();
// Flip above the trigger if there is not enough room below.
setPlacement(spaceBelow < height ? 'top' : 'bottom');
}, [anchor]);With useEffect, the tooltip paints below, then jumps above — one frame of visible wrongness. With useLayoutEffect, both happen before the paint.
The cost is that it blocks painting, so anything slow inside it delays the frame. Use it only for measurement-driven layout adjustments, and use useEffect for everything else. It also does not run during server rendering (Chapter 6.5.1), which produces a warning you will meet eventually.
4. useRef: a box that does not trigger renders
tsx
const inputRef = useRef<HTMLInputElement>(null); // (1) a DOM handle
const renderCount = useRef(0); // (2) a mutable value
renderCount.current += 1; // (3) no re-renderA ref is an object { current } that is the same object across every render. Writing to .current does not schedule a render and is not part of the snapshot model.
Two legitimate uses, and one rule.
Line (1): a handle to a DOM node, for the things React does not model — focus(), scrollIntoView(), measuring, playing a video, integrating a non-React library.
Line (2): a mutable value that should survive renders without causing them — a timer id, the previous value of something, a WebSocket instance, a "has the user interacted yet" flag.
The rule: do not read or write a ref during render. Its value is not part of the render's snapshot, so using it during render makes the output depend on something React does not track, which breaks the purity requirement from Chapter 6.4.1 and misbehaves under concurrent rendering. Read refs in effects and event handlers.
If you find yourself putting something in a ref so the interface updates less, that is usually state that should be state.
5. useMemo and useCallback, honestly
Both cache something between renders. useMemo caches a value; useCallback caches a function (and is exactly useMemo(() => fn, deps)).
They are not free. Each one costs an entry in the hook list, a dependency array to allocate and compare, and one more thing for a reader to hold in their head. Wrapping everything makes code slower and harder to read.
Three cases where they genuinely pay:
An expensive computation. Sorting or filtering thousands of items on every render:
tsx
const sorted = useMemo(
() => [...products].sort(byPrice),
[products],
);Referential identity for a memoised child. React.memo compares props shallowly, so a new function or object on every render defeats it completely:
tsx
// Without useCallback, onSelect is a new function each render,
// so React.memo(ProductList) never skips anything.
const onSelect = useCallback((id: string) => select(id), [select]);
return <ProductList items={items} onSelect={onSelect} />;A dependency of an effect. An object or function in a dependency array is compared by identity, so an unmemoised one re-runs the effect on every render — which is how a useEffect ends up firing in a loop.
A note on the near future. The React Compiler applies this memoisation automatically at build time by analysing what actually depends on what, which removes most hand-written useMemo and useCallback. It does not change any of the reasoning above — it changes who writes it. What it cannot fix is code that is impure, which is one more reason purity is treated as non-negotiable.
6. useReducer, and the hooks you meet less often
useReducer when several pieces of state change together under named actions:
tsx
type State = { status: 'idle' | 'loading' | 'error' | 'ready'; data?: Order[]; error?: string };
type Action = { type: 'load' } | { type: 'ok'; data: Order[] } | { type: 'fail'; error: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'load': return { status: 'loading' };
case 'ok': return { status: 'ready', data: action.data };
case 'fail': return { status: 'error', error: action.error };
}
}Three useState calls could hold the same information and would allow status: 'ready' with an error set and no data — a state that should be impossible. The reducer makes each transition produce a complete, valid state, and the discriminated union (Chapter 3.7.3) means TypeScript will not let you read data without checking status first. This is the same modelling argument as Chapter 9.4.14's State pattern, one layer up.
useId generates an identifier that is stable between server and client rendering — the correct way to link a <label> to an input in a reusable component, where a global counter would produce different values on the two sides and break hydration (Chapter 6.5.1).
useSyncExternalStore subscribes to a store outside React. It exists to prevent tearing: with concurrent rendering, a render can be interrupted, the external store can change mid-render, and two components can then read different values of the same store in one commit — the interface shows two versions of the truth simultaneously. This hook makes React re-read consistently. You rarely call it directly; every state library uses it underneath, and knowing why is the point.
useImperativeHandle customises what a parent gets from a ref to your component. Use it sparingly and expose verbs, not internals — { focus, clear }, never the raw DOM node.
7. Custom hooks are just functions
There is no machinery here. A custom hook is a function that calls hooks, and it composes because the hook list does not care which function made the calls.
tsx
function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // (1) the crucial line
}, [value, delay]);
return debounced;
}Line (1) is what makes it work: each new value cancels the pending timer before starting a new one, so the update only lands after the value has been still for delay milliseconds.
Custom hooks share logic, not state. Two components calling useDebouncedValue get two independent pieces of state, because each call creates its own records on its own fiber. This is the difference from a context or a store, and it is the thing people expect to work the other way round the first time.
8. What StrictMode is doing, and why
In development, <StrictMode> makes React:
- Call your component function twice per render.
- Mount, unmount and remount every component once on first mount, so every effect runs setup → cleanup → setup.
- Double-invoke reducers and state initialisers.
None of this happens in production. It is a test harness, and it is checking two specific things.
The double render checks purity. If rendering twice produces a different result, your component has a side effect or reads something it should not. The bug it catches:
tsx
let idCounter = 0;
function Item() {
const id = `item-${idCounter++}`; // impure — a new id every render
…
}The double effect checks that your cleanup is a real undo. React 18 added it deliberately in preparation for state being preserved across unmount and remount — a returning user restoring a screen should get their component back with its state, and that only works if every effect can be torn down and set up again cleanly. If setup-cleanup-setup leaves you with two sockets, two intervals or two subscriptions, you have a leak that would also occur in production the first time anything remounts.
The correct response to "my effect runs twice" is never to disable StrictMode. It is to write the cleanup that should already have been there. If double-mounting genuinely breaks something and there is no cleanup that can fix it — a one-off analytics call, for instance — that is a signal the work does not belong in an effect at all.
What the interviewer will push on
"Why can't hooks be called conditionally?" Because they are stored as an ordered list on the fiber and matched by call order, not by name. Skipping one shifts every subsequent hook to the wrong record. Deriving the rule from the mechanism is the answer; quoting the rule is not.
"Why does calling setCount(count + 1) three times only add one?" count is a constant within that render's snapshot, so all three compute the same value. setCount(c => c + 1) applies against the latest value. This is the question that tests whether you have internalised the snapshot model.
"What is a stale closure?" A callback captured the values of the render it was created in and outlived them. The fixes, in order: functional updates, honest dependencies, or a ref holding the latest callback. Add the discipline — never remove a dependency to silence the linter.
"What does the dependency array mean?" Not a schedule — the list of values from this render that the effect uses. Under that reading the correct array is derived, not chosen.
"When would you use useLayoutEffect?" Measure-then-adjust, where an intermediate paint would flicker — tooltip placement, scroll restoration. Then name the cost: it blocks painting, and it does not run on the server.
"When do you reach for useMemo?" Genuinely expensive computation, referential identity for a memoised child, or a dependency of an effect. Not by default — each one has a cost, and the React Compiler is making the hand-written version obsolete anyway.
"Why does my effect run twice in development?" StrictMode simulates a remount to verify the cleanup is a real undo, because React intends to preserve and restore state across unmounts. The fix is the missing cleanup, never turning StrictMode off.
"Why does useSyncExternalStore exist?" To prevent tearing — under concurrent rendering, an external store can change mid-render and two components can commit different values of the same data. Knowing the failure mode by name is the differentiator here.
One thing to volunteer: bring up the fetch race — click user 1, click user 2, the slower first response overwrites the second — and the cancelled flag plus AbortController in the cleanup. Almost every hand-rolled data fetch has this bug, it is invisible on a fast connection, and naming it shows you have debugged production rather than tutorials.
Recall
- Hooks live as an ordered linked list on the fiber, matched by call order, not name. Every rule of hooks is a consequence of that, including why the
useprefix exists (it is what lets the linter work). - A render is a snapshot. Props, state and closures belong to one render and never change.
setCount(count + 1)three times adds one;setCount(c => c + 1)adds three. useState(() => expensive())runs once;useState(expensive())runs every render and throws the result away. Setting state to anObject.is-equal value bails out, which is why mutating an array and re-setting it does nothing.- An effect synchronises something outside React with this render's values. The dependency array is what the effect used, not a schedule. Cleanup runs before the next effect and on unmount — it is an undo, not a destructor.
- The stale closure fixes in order: functional update · honest dependency · ref holding the latest callback. Never delete a dependency to silence the linter.
- Every hand-written fetch needs a
cancelledflag andAbortControllerin the cleanup, or a slow earlier response overwrites a newer one. - Do not use an effect for derived state (compute during render), for event responses (use the handler), or to reset on a prop change (use a
key). useLayoutEffectruns before paint — for measure-then-adjust only, because it blocks painting and does not run on the server.- A ref is a stable
{ current }box that does not trigger renders; do not read or write it during render. useMemo/useCallbackearn their place for expensive computation, referential identity forReact.memochildren, and effect dependencies — not by default.useReducerwhen transitions are coupled and some combinations should be impossible.useIdfor server-stable ids.useSyncExternalStoreprevents tearing — two components committing different values of the same store.- StrictMode double-renders to check purity and remounts to check that cleanup is a real undo. The fix is always the missing cleanup.
Self-test: Why does an early return before a hook break React? · Why does the log inside a click handler print the old count? · What does the dependency array actually list? · Which three fixes remove a stale closure, and which is best? · What race does every hand-rolled fetch have? · What is StrictMode's remount actually testing?
Next: 6.4.3 steps outside the component — where state should live when several components need it, what actually differs between the three common answers, and the two problems everyone hits with a store: collisions between features and getting persisted state back without breaking the first render.