React Hooks Interview Playbook
The four questions they ask about hooks: rules, stale closures, effect dependencies, and custom-hook design, with answers that score.
- interview
- react
- hooks
The usual React hooks miss is not forgetting a hook name. It is calling useState after an if, or shipping a setInterval that prints 0 forever. Both look like fluency. Both collapse on the follow-up.
This is a playbook for a React hooks interview, not a catalog and not a hook library. Four questions score: rules of hooks, stale closures, effect dependency arrays, and custom-hook design. Say the model, then fix a short bug without restarting work for the wrong reason. Implementations live on the linked pages. For breadth, use the React interview question map.
Key takeaways
- Hooks are a slot list for one component instance. Same calls, same order, every render.
- A scheduled callback closes over that render. Functional
setStatefixes updates; a ref (or honest deps) fixes reads.- The dependency array is an
Object.iscontract: omit a read and you are stale; list a fresh object and you loop.- A custom hook shares logic, not state. Name, inputs, return shape, and cleanup are the scoring surface.
The four questions they ask about hooks, and the answers that score
| Question | What they score | Spoken core |
|---|---|---|
| Rules of Hooks | Call order, not style | Slots, not names. Branch after the calls. |
| Stale closures | Tracing a later callback | The interval sees the render that created it. |
| Effect deps | Object.is plus cleanup |
List what you read, or delete the effect. |
| Custom-hook design | API, not a gist | use prefix, own slots, abort and timers cleaned up. |
Interviewers often stack two in one prompt (“fix this effect”). Memorizing eslint-plugin-react-hooks still fails the timer that should not reset.
1. Rules of Hooks
React records hook calls in source order for that instance. Slot 0 is the first useState; names are for you. Custom hooks append slots in the order they call hooks. Official rule: Rules of Hooks. Depth: rules of hooks.
Conditional calls, loops, nested functions, and hooks after an early return shift later slots. Next render, React wires the wrong state. The fix is not “never use if.” Always call the hook; branch the behavior. Extract a child if each list row needs its own state. Do not useState inside map.
“Hooks are a slot list, not a map from names. I call them at the top, before any return that depends on props. If the UI branches, the hooks still ran.
rules-of-hooksis a blocker;exhaustive-depsis a different rule.”
2. Stale closures
Function components close over that render’s props and state. setInterval, addEventListener, and then do not re-read count. They keep the binding from the function that scheduled them.
function Poll() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current);
}, 1000);
return () => clearInterval(id);
}, []);
return (
<button type="button" onClick={() => setCount((n) => n + 1)}>
clicks {count}
</button>
);
}
console.log(count) with [] prints 0 forever. Putting count in the interval deps refreshes the log and restarts the timer on every click. Name both failure modes.
Functional updates (setCount((n) => n + 1)) fix writes that depend on previous state. They do not fix a log of count. A ref for the latest read, with the subscription effect depending only on the real key (here, “one interval”), fixes reads without resetting the timer. That split is the useInterval interview. usePrevious is the sibling: a ref that lags one commit. Write it in an effect, not during render, so a discarded concurrent render cannot clobber it.
“The callback closed over the render that created it. Empty deps freeze those bindings. Adding the value restarts the effect, which is wrong for a timer. I keep the latest read in a ref.”
3. Effect dependency arrays
The array is not a performance hint. React compares each entry to the last commit with Object.is. Docs: useEffect. Practice: useEffect dependency array.
| Shape | After commit |
|---|---|
| omitted | Re-run every time (almost always a smell) |
[] |
Setup after mount; cleanup on unmount |
[url, delay] |
Re-run when a listed value fails Object.is |
If the effect reads query, list query. Missing it means you never refetch. Listing an options object the parent creates inline means you refetch every parent render. Cleanup runs before the next setup and on unmount: clearInterval, removeEventListener, controller.abort(). Strict Mode runs setup, cleanup, setup; a missing cleanup looks like a double fetch, and it is.
If you are copying props into state or filtering a list during render, you do not need an effect. Lying to the linter with [] is how the stale log ships.
“Deps are a contract. Everything the effect reads is listed. A new object every render is a resubscribe. I change the input identity or I delete the effect. I do not disable exhaustive-deps to hide a stale read.”
4. Custom-hook design
A custom hook is a use* function that calls other hooks. It shares stateful logic, not one state across callers. Two useFetch(url) calls are two machines. Docs: reusing logic with custom hooks. Design: custom hooks design.
Score the API:
- Name the product concept (
useDebouncedValue, notuseUtils). - Inputs are the minimum reactive values (
userId, not the props bag). - Return a value, a
useState-like tuple, or a named object when there are more than two fields. - Hooks stay unconditional. Branch after the calls (
url == null→ idle). - Cleanup lives in the hook. Callers should not have to abort.
Do not invent useMount. Do not return JSX. Do not hide a module-level cache and call it a hook.
The fetch follow-up is the race: type "re", then "react", and the slow first response must not win. New AbortController per effect run, signal into fetch, abort() in cleanup, ignore AbortError, still treat HTTP 404 as an error because fetch fulfills. Walkthrough: useFetch with abort.
“I would extract a use-prefixed function that calls hooks unconditionally and returns status, data, and error. Each caller gets its own state. The effect depends on url, aborts on cleanup, and does not list a fresh options object.”
Footguns
useEffectto computefullNamefromfirstandlastinstead of deriving during render.setStateof a new object inside an effect that depends on that state: loop.- Assigning “previous” on a ref during render under concurrent React. Prefer the effect write.
- Treating custom hooks as singletons. They are not.
- Swallowing every
fetchfailure so abort and a 500 look the same.
When stale and deps collide, pick the tool from the failure: wrong value versus reset subscription.
Interview out-loud answer
“Hooks are a slot list, so I call them at the top and branch afterward. A timer or listener closes over the render that scheduled it; functional setState fixes queued updates, a ref fixes reads without resetting the subscription. The dependency array is Object.is: list what I read, never a fresh object from this render, and delete the effect if there is no external system. A custom hook is use-prefixed logic with its own slots and cleanup, not shared state and not JSX. For fetch I abort on cleanup and ignore AbortError only.”