Index

Hooks: useState, useEffect and the rest

Hooks look like a small API and interview like a big one, because almost every question about them is really a question about JavaScript closures. A component body is a function that runs again on every render, so each render has its own count, its own props, and its own copy of every function you defined inside it. Nearly every hooks bug - the counter that advances by one, the interval that logs zero forever, the effect that never stops - is a render reading a value that a later render replaced. Say that sentence early and the follow-ups get easier.

The rule, and what it is protecting

React does not know the names of your hooks. When a component renders, React walks a list attached to that component instance and hands out state cell 0, then cell 1, then cell 2, in the order the hook calls happen. useState does not say "give me the state called count" - it says "give me the next cell", and the only thing making that reliable is that the same calls happen in the same order every single render.

That is the whole reason for the rules. Call hooks at the top level, never inside a condition, a loop, a nested function or after an early return, and only from a React function component or another hook. Break it and cell 1 is a piece of state on one render and a ref on the next - so React either throws "Rendered more hooks than during the previous render" or, worse, silently hands one hook another hook's value.

The practical consequence people forget: the fix is never to move the hook, it is to move the condition INSIDE the hook. if (isOpen) useEffect(...) is illegal; useEffect(() => { if (isOpen) ... }) is fine and does the same thing.

Render
React calls your component function and gets back a description of the UI. Pure - no DOM has changed yet, nothing has been painted, and React is allowed to throw the result away or run it twice.
Commit
React applies the difference to the real DOM. useLayoutEffect runs here, synchronously, before the browser paints - which is why it can measure and fix layout without a flicker, and why blocking in it blocks the frame.
Paint, then effects
The browser paints. useEffect callbacks run after that, asynchronously. This is why an effect is the wrong place to compute something the render needed: by the time it runs, the user has already seen the render without it.

What an effect actually is

An effect is not a lifecycle callback. The mental model that survives every follow-up is synchronisation: this effect says "as long as these dependencies hold these values, this outside thing should be set up". React's job is to keep that true - so it runs the setup, and when a dependency changes it runs your cleanup for the OLD values and the setup for the new ones, in that order.

So the dependency array is not a performance knob and it is not a list of "when to fire". It is the list of every value from the render scope that the effect body reads. Leave one out and the effect keeps working against a stale copy of it forever; that is the same bug as the stale-closure timer, just spelled differently.

Cleanup runs in exactly three situations: before the effect re-runs because a dependency changed, when the component unmounts, and - in development under Strict Mode - immediately after the first setup, on purpose, to prove the cleanup works.

Questions

Answer each one yourself before you open it.

  1. 01What is a hook?

    A function starting with use that lets a function component hook into React features - state, effects, context, refs - that used to require a class. React tracks the calls per component instance, in call order.

    The use prefix is not decoration: it is what the lint rule and React's own dev warnings key on to decide whether the rules of hooks apply to a function. Anything you write that itself calls a hook must also be named useSomething, or the tooling cannot tell it apart from a plain helper.

    Follow-up Where can you call one?

    Only from a React function component or from another hook. Not from a class, not from a plain utility function, not from an event handler.

  2. 02Why do hooks exist? What was wrong with classes?

    Reusing stateful logic between classes needed render props or higher-order components, which nested the tree without adding meaning. And related logic got split across lifecycle methods while unrelated logic got merged into one - a subscription's setup in componentDidMount and its teardown in componentWillUnmount, with a fetch sitting between them.

    Custom hooks solve the reuse problem without touching the tree: the logic moves into a function, the component stays where it was, and the data flow stays visible because you can see what the hook returns. The lifecycle problem is solved by making the unit of code the concern rather than the moment - one effect per thing being synchronised, each with its own cleanup next to its own setup.

    Follow-up Anything else?

    this. Classes made you bind handlers and made this.state easy to read at the wrong moment; a function component closes over the values of one render, which is clearer once you accept it and confusing until you do.

  3. 03What are the rules of hooks?

    Call them at the top level - never inside a condition, a loop, a nested function, or after an early return - and call them only from React function components or from other hooks.

    Both rules exist so React can rely on call order. There is no name lookup: state is stored in a list per component instance and matched to hook calls positionally.

    Follow-up How is that enforced?

    The eslint-plugin-react-hooks rule catches it statically, and React throws at runtime when the number of hooks changes between renders.

  4. 04What actually breaks if a hook sits inside an if?

    The hook list shifts. If the condition is true on the first render and false on the next, every hook after it moves up one slot - so a piece of state gets handed another hook's cell, and React throws "Rendered more hooks than during the previous render".

    React stores hooks per component instance as an ordered list and increments a cursor on each call. On the render where the count drops, the cursor runs off the end of the list; on the render where it grows, cell N holds whatever the previous render put there, which might be a ref or an effect's dependency array. The error is React noticing, and the silent mismatch is what would happen if it did not.

    Follow-up So how do you make an effect conditional?

    Put the condition inside the callback: useEffect(() => { if (!id) return; ... }, [id]). The hook is always called; the work is not always done.

  5. 05Can you call a hook in a loop, or after an early return?

    No to both, for the same reason - the number of calls has to be identical every render. If you need N pieces of state for N items, render N child components and give each one its own useState.

    An early return is the sneaky version: the code is at the top level and looks legal, but a render that returns early makes fewer hook calls than one that does not. Put every hook above every return, including the loading and error guards.

  6. 06Does useState's setter merge like this.setState did?

    No. It replaces. setUser({ name: 'a' }) on a user that had a name and an email leaves you with an object that has only a name.

    Class setState did a shallow merge of the object you passed into this.state. useState has no such contract because the state does not have to be an object at all. If you keep an object in state, spread it yourself: setUser(u => ({ ...u, name })).

  7. 07Is setState synchronous? What is the value right after you call it?

    Not synchronous. The setter schedules a re-render; the count variable in the function you are standing in is a const from this render and will never change. You read the new value on the next render.

    Each render is a separate call of the component function, with its own scope. The setter does not assign to a variable - it tells React that the next render of this component should start from a new value. Logging count on the line after setCount(count + 1) prints the old number, every time, and that is not a bug.

    Follow-up What if you genuinely need the new value immediately?

    Compute it into a local first - const next = count + 1; setCount(next); doSomething(next) - or move the work into an effect keyed on the value.

  8. 08Why do three setCount(count + 1) calls only advance the counter by one?

    All three read the same count from this render's closure. If count is 0 they all compute 1, so React queues "set it to 1" three times and the result is 1.

    count is a const captured by that render. The setter takes a value, not an instruction, so the last write wins and all three writes are the same number. Swapping to setCount(c => c + 1) queues three functions instead of three values; React applies them in order against the running result, so 0 becomes 1 becomes 2 becomes 3.

    Follow-up So should everything be a functional updater?

    No - use it whenever the next value depends on the previous one, or when the setter runs from inside a closure that might be stale (an interval, a debounce, an async callback). For a value that comes from the event, setName(e.target.value) is right as it is.

  9. 09What is lazy initial state?

    Passing a function to useState instead of a value: useState(() => expensiveParse(raw)). React calls it once, on the first render only. Passing useState(expensiveParse(raw)) calls it on every render and throws the result away every time after the first.

    Arguments are evaluated before the call. useState(x) receives an already-computed x no matter which render it is; React ignores it after the first, but your CPU does not. The function form defers the work to React, which only invokes it when it actually needs an initial value.

    Follow-up How do you store a function in state, then?

    Wrap it: useState(() => myFn). Otherwise React calls your function as the initialiser.

  10. 10What is batching, and did it change in React 18?

    Batching is React grouping several state updates into one re-render. React 17 batched only inside React event handlers; React 18 batches everywhere by default - in timeouts, promises, and native event handlers too. That is automatic batching.

    Before 18, two setters inside a setTimeout produced two renders; the same two inside an onClick produced one. The inconsistency was invisible until it wasn't. React 18's createRoot batches all of them, which is why a codebase that relied on an intermediate render to appear can behave differently after the upgrade.

    Follow-up How do you opt out?

    flushSync(() => setX(1)) from react-dom forces that update to flush synchronously. Reach for it when you must read the DOM immediately after - measuring a newly rendered node, or scrolling to it - and nowhere else.

  11. 11What does useEffect do?

    It runs a function after render is committed and the browser has painted, and lets you return a cleanup function. It is for synchronising with something outside React - the network, a subscription, a timer, the document title.

    The key word is 'after'. The effect does not participate in producing the render output; it reacts to it. So anything the render needed in order to be correct does not belong in an effect - the user has already seen the frame by the time it runs.

  12. 12What are the three dependency-array forms?

    No array: runs after every render. Empty array: runs after the first render only. An array with values: runs after the first render and again after any render where one of those values changed by Object.is.

    The comparison is shallow and identity-based, which is the source of most surprises: a new object or array literal is a different value every render even when it looks the same, so an effect depending on one runs forever.

    Follow-up Is the empty array the same as componentDidMount?

    Close enough to say out loud, but the honest answer is no - it means 'this effect depends on nothing', and under Strict Mode in development it still runs setup, cleanup, setup. If your code only works because it ran exactly once, it is fragile.

  13. 13When exactly does React call the cleanup function?

    Three times: before re-running the effect because a dependency changed, when the component unmounts, and - in development Strict Mode - right after the first setup, deliberately.

    On a dependency change React runs the OLD effect's cleanup before the NEW effect's setup, so the subscription for the old id is torn down before the one for the new id goes up. The cleanup closes over the old render's values, which is exactly what you want: it has the old timer id and the old subscription object.

  14. 14Give me the three classic cleanups.

    Unsubscribe from a subscription, clear a timer or interval, and abort an in-flight fetch with an AbortController. Every effect that starts something long-lived needs one.

    Without the first two you leak: a component that mounts and unmounts in a list leaves an interval running per mount, each closing over dead state. Without the third you get the race - a response from a request nobody is waiting for arriving after a newer one, and overwriting it.

    Follow-up Show the shape.

    useEffect(() => { const id = setInterval(tick, 1000); return () => clearInterval(id); }, []) - set up, return the teardown, and let the deps decide when the pair repeats.

  15. 15Map the class lifecycle methods to hooks.

    componentDidMount is useEffect(fn, []). componentDidUpdate is useEffect(fn, [deps]). componentWillUnmount is the cleanup you return. getDerivedStateFromProps is usually just computing during render, and shouldComponentUpdate is React.memo.

    The mapping is useful for translating old code and misleading as a mental model. Classes ask 'what moment is this?'; effects ask 'what is this synchronised with?'. One class component with a subscription and a fetch becomes two effects, not one effect with two branches.

    Follow-up What has no hook equivalent?

    getSnapshotBeforeUpdate and error boundaries. Error boundaries still require a class component - componentDidCatch and getDerivedStateFromError have no hook, so you use a class or a library wrapper.

  16. 16My effect fires in an infinite loop. What did I do?

    One of two things: the effect calls setState unconditionally with no dependency array, so the render it causes runs the effect again; or a dependency is an object, array or function created fresh in the component body, so it is a new identity on every render.

    React compares dependencies with Object.is. { limit: 10 } is not Object.is-equal to another { limit: 10 }, and a function defined in the component body is a new function every render. So the deps 'change' every time even when nothing meaningful did, the effect re-runs, and if it sets state the render feeds it back.

    Follow-up The two fixes?

    Either stabilise the identity - useMemo for the object, useCallback for the function, or hoist a constant outside the component entirely - or depend on the primitive inside it: [options.limit] instead of [options]. Hoisting is the best of the three when the value never changes, because it removes the dependency instead of memoising it.

  17. 17What is a stale closure?

    A function that outlives the render it was created in, and keeps reading that render's values. The classic is an interval set up once with [] deps: it captured count as 0 and logs 0 forever, no matter how many times you click.

    The callback passed to setInterval closes over the variables of the render that created it. [] says 'never re-run this effect', so that callback is never replaced - and the count it can see is frozen at the value from the first render. The UI updates because state changed; the interval does not, because it is holding a photograph.

    Follow-up Both fixes?

    Use the functional updater so the callback never needs to read state - setCount(c => c + 1) - or keep the latest value in a ref that an effect updates every render, and read ref.current inside the interval. The updater is the right answer when you only need the previous value; the ref is the right answer when you need something you cannot derive, like the latest props.

  18. 18Why not just add count to the interval effect's dependencies?

    It works, but it tears down and recreates the interval on every tick, so the timer restarts and drifts. Correct output, wrong mechanism - and an interviewer will ask what happens to the timing.

    The effect's job is 'keep one interval running'. Depending on count redefines the job as 'keep one interval running per value of count', which is not what you meant. That is the tell that the dependency belongs inside the updater or a ref rather than in the array.

  19. 19useEffect vs useLayoutEffect?

    Same API. useLayoutEffect runs synchronously after the DOM is mutated but before the browser paints; useEffect runs after paint. Default to useEffect and reach for the layout one only when you have to measure the DOM and change it before the user can see the intermediate state.

    The visible difference is flicker. Measuring a tooltip and repositioning it in useEffect means the browser paints it in the wrong place first; in useLayoutEffect the reposition happens in the same frame. The cost is that it blocks painting, so slow work there freezes the frame - and it does not run during server rendering, which produces a warning if you use it in a component that is server-rendered.

  20. 20When should you NOT use an effect?

    When the value can be computed during render from props and state. If you have fullName in state and an effect syncing it from first and last, delete both - just compute const fullName = first + ' ' + last in the body.

    An effect that sets state causes a second render pass with a stale first paint in between, adds a source of truth that can disagree, and puts the derivation somewhere the render does not obviously depend on. The rule of thumb: effects are for talking to things outside React. Transforming data for rendering, and handling a user event, are not that.

    Follow-up What if the derivation is expensive?

    useMemo it. Still during render, still one source of truth, no extra pass.

  21. 21How do you reset state when a prop changes, if not with an effect?

    Give the component a key. <Profile key={userId} userId={userId} /> - when the key changes React unmounts the old instance and mounts a fresh one, so all its state resets. No effect, no if (prevProps.userId !== ...).

    The key is the identity of the element for reconciliation. Changing it is React's built-in way of saying 'this is a different thing now', and it is far more reliable than remembering every piece of state to clear. This is the single most useful non-obvious trick on this page.

    Follow-up And to reset only part of it?

    Adjust the state during render: compare a prop to a previous-value state and set state right there in the body. React re-runs the component immediately without committing the intermediate render. It is the documented escape hatch, and it is still better than an effect.

  22. 22What is useRef for?

    Two things. Holding a reference to a DOM node - <input ref={inputRef} /> then inputRef.current.focus() - and holding any mutable value that should survive renders without causing one, like a timer id or the latest props.

    The object identity from useRef is stable for the component's whole life, and writing to .current does not schedule a render. That is the entire difference from state: state is what the UI is drawn from, a ref is what the component remembers.

    Follow-up When is a ref the wrong choice?

    When the UI has to change with the value. Nothing re-reads a ref, so the screen keeps showing the old number until something else happens to re-render.

  23. 23Does mutating a ref re-render the component?

    No. That is the point. Assigning to ref.current is a plain property write - React does not track it and nothing repaints.

    It also means you must not read or write refs during render for anything that affects the output: that makes the render impure and breaks under concurrent rendering. Read and write refs in effects and event handlers.

  24. 24useMemo, useCallback, useRef - when each?

    useMemo caches a computed value between renders. useCallback caches a function identity - it is useMemo(() => fn, deps) with a nicer name. useRef holds a mutable box that never changes identity and never triggers a render.

    Memoising is not free: React still has to store the value and compare the deps, and every useCallback you add is code someone has to keep the deps of correct. Both earn their place in two situations - the computation is genuinely expensive, or the identity is a dependency of something that compares by identity (a memo child, another hook's deps).

    Follow-up So should you memoise everything?

    No. Wrapping a callback that is passed to a plain DOM onClick buys nothing, because a DOM node does not re-render on a new handler identity.

  25. 25What is useReducer and when does it beat useState?

    const [state, dispatch] = useReducer(reducer, initialState) - the reducer is a pure (state, action) => newState. It wins when several pieces of state change together, when the next state depends on the previous in complicated ways, or when the same transition is fired from many places.

    It moves the update logic out of the event handlers and into one function you can read in isolation and unit test without rendering anything. It also fixes stale closures for free: the reducer always receives the current state as its first argument, so a dispatch from inside an interval is never stale. And dispatch has a stable identity, so passing it down never invalidates a memoised child.

    Follow-up Is a form better with reducer or state?

    One state object with a generic onChange is usually enough. Reach for a reducer once validation, touched flags and async submission start interacting - that is four fields moving as one transition.

  26. 26What is useContext for?

    Reading a value provided higher up the tree without passing it through every level. const theme = useContext(ThemeContext) subscribes that component to the nearest matching provider above it.

    Context solves prop drilling, not state management. It has no reducer, no selectors and no subscription granularity: every consumer re-renders when the provider's value changes by identity, whether or not the part it reads changed.

    Follow-up What happens with no provider above it?

    You get the default value passed to createContext - which is why the default should usually be undefined and your custom hook should throw a clear error, rather than silently handing back a plausible-looking blank object.

  27. 27What is useId for?

    Generating a stable unique id that matches between server and client render, for hooking a label to an input. const id = useId() then htmlFor={id} and id={id}.

    The alternative people reach for is a counter or Math.random(), and both break server rendering: the server produces one id, the client produces another, and hydration mismatches. It is explicitly not for list keys - the key should come from your data.

  28. 28What is useTransition?

    const [isPending, startTransition] = useTransition() marks a state update as non-urgent. React keeps the old UI interactive and renders the new one in the background, and isPending lets you show a subtle indicator.

    The case it is built for is a typed filter over a big list: the input value must update on every keystroke or typing feels broken, but the expensive filtered list can lag a frame or two. Marking the list update as a transition means React can interrupt and restart it when the next keystroke arrives instead of blocking on it.

  29. 29And useDeferredValue?

    const deferred = useDeferredValue(value) gives you a copy of a value that is allowed to lag behind. Same idea as a transition, but from the consuming side - use it when you receive a value as a prop and cannot wrap the setter.

    React renders once with the new value where it is urgent and keeps the old one where you used the deferred copy, then re-renders the deferred part at lower priority. In practice: value goes to the input, deferred goes to the expensive list.

  30. 30What is useSyncExternalStore?

    The supported way to subscribe a component to a store that lives outside React - a Redux-style store, a browser API like matchMedia or navigator.onLine. You pass a subscribe function and a getSnapshot function.

    It exists because concurrent rendering made the old pattern - subscribe in an effect and copy into state - capable of tearing: two components reading the same external value during one interruptible render could see different versions of it. useSyncExternalStore forces a consistent read, and takes a third argument for the server snapshot.

  31. 31What makes something a custom hook?

    It is a function whose name starts with use and which calls at least one other hook. There is nothing else to it - no registration, no special API.

    The value is that the rules of hooks travel with the naming convention, so the lint rule can check inside your function too. And because it is a plain function call, the state it creates belongs to the component that called it, not to the hook.

  32. 32If two components use the same custom hook, do they share state?

    No. Each call creates its own state. A custom hook shares LOGIC, not data - two components calling useCounter() get two independent counters.

    This is the question that separates people who have used hooks from people who have read about them. useState inside the custom hook is still useState in whichever component ultimately called it, and hooks are per component instance. If you want two components to share a value, you have to lift it - context, a store, or a common parent.

    Follow-up How would you make them share it?

    Put the hook's state in a provider and have the hook read context instead of owning state. The call sites do not change; only the hook's body does.

  33. 33Why do my effects run twice on mount in development?

    Strict Mode. In development React 18 intentionally mounts, unmounts and remounts each component once, so every effect runs setup, cleanup, setup. It does not happen in production builds.

    It is a test you cannot opt out of by accident: an effect that breaks when it runs twice is an effect with a missing or wrong cleanup, and that same bug shows up in production the first time the component remounts or a Fast Refresh happens. The fix is never to add a hasRun ref - it is to write the cleanup so setup-cleanup-setup ends in the same state as setup.

    Follow-up What about the duplicate fetch it causes?

    Abort it in the cleanup, or use a data library that dedupes. The double request is the symptom telling you the first request had no teardown.

  34. 34Can you use hooks in a class component?

    No. Hooks only work in function components. To use hook-based logic from a class, wrap the class in a small function component that calls the hook and passes the result down as a prop.

  35. 35What is the exhaustive-deps lint rule telling you, and when do you ignore it?

    It is telling you the effect reads a value from the render scope that is not in the dependency array - which means the effect will keep using the value from the render it was created in. Almost always the right move is to add it or to restructure so it is not needed.

    The legitimate suppressions are narrow: a mount-only side effect like an analytics ping, or a value you deliberately want frozen. Even then, the honest fix is usually a ref holding the latest value, with a comment saying why. Disabling the rule to stop a loop is treating the alarm instead of the fire - the loop is an identity problem, and silencing the deps just hides it.

  36. 36Why does my input lose focus every keystroke?

    You defined the component inside another component's body. Every render creates a new function, so React sees a different component type, unmounts the old subtree and mounts a new one - new DOM node, no focus, and all its state gone.

    Reconciliation compares element types by identity. function Parent() { function Field() {...} return <Field /> } produces a different Field on every render, which is never a match. Move the definition to module scope and pass what it needs as props. The same symptom appears when a list key changes every render, for the same reason.

  37. 37What is useImperativeHandle?

    It customises what a parent gets when it puts a ref on your component - used with forwardRef to expose a small imperative API like focus() or scrollToTop() instead of the raw DOM node.

    Reach for it rarely. It is the escape hatch for genuinely imperative things - focus, scroll position, media playback - and every use of it is a bit of state living outside React's data flow.

  38. 38How do you decide between useState and a ref for a value?

    Ask whether the screen has to change when it changes. If yes, state. If it is bookkeeping - a timer id, a previous value, whether you have already scrolled - a ref.

    Putting bookkeeping in state produces renders nobody asked for; putting display data in a ref produces a UI that quietly lags. The test is one question and it answers almost every case.

  39. 39Two components need the same state. Where does it go?

    In their closest common parent, passed down as props, with a callback passed down for the child to change it. That is lifting state up.

    It is the same answer as three years ago and it is still the first answer. Context and a store are what you reach for when 'closest common parent' turns out to be the app root and the value has to cross ten layers to get anywhere.

  40. 40What is the biggest difference between state in a class and state with hooks?

    this.state is a mutable box you read at the moment you read it; hook state is a value captured by each render. A class handler that reads this.state.count after an await sees the newest value; a function component's handler sees the value from the render that created it.

    That single difference explains the counter bug, the timer bug and most async-handler surprises. It is also why the functional updater exists: it is the way to ask for 'whatever the value is now' without reading a variable.

  41. 41How do you test a custom hook?

    Render it. Either mount a tiny test component that calls it, or use renderHook from React Testing Library, and wrap state changes in act.

    Hooks cannot be called outside a render, so there is no way to call one directly in a test. Anything that changes state has to go through act so React flushes the update and effects before your assertion runs.

Predict the output

The output is hidden. Predict it before you open it.

Three setters, one value

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

  function bump() {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  }

  console.log("render", count);
  return <button onClick={bump}>{count}</button>;
}

// mounted, then the button is clicked once
Show the output
render 0
render 1

All three calls read count from the first render's closure, where it is 0, so all three queue the value 1. React batches them into one re-render and the button shows 1. Two renders total, not four.

The same code with the functional updater

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

  function bump() {
    setCount((c) => c + 1);
    setCount((c) => c + 1);
    setCount((c) => c + 1);
  }

  console.log("render", count);
  return <button onClick={bump}>{count}</button>;
}

// mounted, then the button is clicked once
Show the output
render 0
render 3

Now React queues three functions instead of three values and applies them in order to the running result: 0 to 1 to 2 to 3. Still one re-render, because the batching is unchanged - only what was queued changed.

Mixing the two forms

const [n, setN] = useState(0);

function go() {
  setN(n + 1);
  setN((c) => c + 1);
  setN(n + 1);
}

// one click from n === 0
Show the output
1

Queue in order: set to 1, then increment (2), then set to 1 again - because that third call still reads n from this render, which is 0. The last plain setter discards everything the updater did. This is why mixing the forms in one handler is a bug waiting to happen.

Lazy initial state

function makeInitial() {
  console.log("make");
  return 0;
}

function App() {
  const [v] = useState(makeInitial());
  const [, setTick] = useState(0);
  return <button onClick={() => setTick((t) => t + 1)}>{v}</button>;
}

// mounted, then clicked once
Show the output
make
make

makeInitial() is an argument, so it is evaluated before useState is called - on every render. React ignores the result after the first render, but the work still happens. Writing useState(makeInitial) passes the function itself and React calls it once, printing make a single time.

Effect and cleanup ordering

function Row({ id }) {
  useEffect(() => {
    console.log("setup", id);
    return () => console.log("cleanup", id);
  }, [id]);
  return null;
}

// mounted with id={1}, then re-rendered with id={2}, then unmounted
Show the output
setup 1
cleanup 1
setup 2
cleanup 2

On a dependency change React runs the previous effect's cleanup before the new setup, and the cleanup closes over the OLD render's values - so it logs 1, not 2. On unmount the last cleanup runs alone. (Under Strict Mode in development there is an extra setup 1 / cleanup 1 pair at the start.)

The interval that never learns

function Timer() {
  const [n, setN] = useState(0);

  useEffect(() => {
    const id = setInterval(() => console.log("tick", n), 1000);
    return () => clearInterval(id);
  }, []);

  return <button onClick={() => setN(n + 1)}>{n}</button>;
}

// mounted, clicked three times in the first second
Show the output
tick 0
tick 0
tick 0
… forever

The [] dependency array means the effect never re-runs, so the callback handed to setInterval is the one created by the first render - and the only n it can see is that render's, which is 0. The button label updates fine, because that is re-rendered; the closure is not.

Same timer, fixed with the updater

function Timer() {
  const [n, setN] = useState(0);

  useEffect(() => {
    const id = setInterval(() => setN((c) => c + 1), 1000);
    return () => clearInterval(id);
  }, []);

  console.log("render", n);
  return <span>{n}</span>;
}

// mounted, three seconds pass
Show the output
render 0
render 1
render 2
render 3

The callback no longer reads any render value - it asks React for the current one. The effect can stay mounted forever with empty deps and still be correct, and the interval is never torn down and restarted.

Object identity in a dependency array

const a = { limit: 10 };
const b = { limit: 10 };

console.log(a === b);
console.log(Object.is(a, b));
console.log([1, 2] === [1, 2]);
console.log((() => {}) === (() => {}));
Show the output
false
false
false
false

Dependency arrays are compared with Object.is. Every literal object, array and function created in a component body is a brand-new value each render, so a dependency on one is a dependency that always looks changed. That is the whole mechanism behind the infinite effect loop.

How many times does this effect run?

function User({ userId }) {
  const options = { userId };

  useEffect(() => {
    console.log("fetch", options.userId);
  }, [options]);

  return null;
}

// rendered with userId={1}, then the parent re-renders it twice
// more with the SAME userId={1}
Show the output
fetch 1
fetch 1
fetch 1

options is a fresh object on every render, so the dependency never compares equal and the effect runs after every render even though nothing about the user changed. Depending on [userId] - the primitive inside it - runs it once.

Layout effect versus effect

function App() {
  useEffect(() => console.log("effect"));
  useLayoutEffect(() => console.log("layout"));
  console.log("render");
  return null;
}
Show the output
render
layout
effect

The body runs first. useLayoutEffect fires synchronously after the DOM is mutated and before the browser paints; useEffect fires after paint. Declaration order in the file does not change this - the phase does.

A ref does not re-render

function App() {
  const count = useRef(0);
  console.log("render", count.current);

  return (
    <button
      onClick={() => {
        count.current += 1;
        console.log("ref is now", count.current);
      }}
    >
      go
    </button>
  );
}

// mounted, then clicked twice
Show the output
render 0
ref is now 1
ref is now 2

Writing to .current is a plain property assignment. Nothing re-renders, so the render line never prints again and any UI drawn from count.current would still show 0. That is the difference from state, in three log lines.

Two components, one custom hook

function useCounter() {
  const [n, setN] = useState(0);
  return [n, () => setN((c) => c + 1)];
}

function A() {
  const [n, inc] = useCounter();
  console.log("A", n);
  return <button onClick={inc}>A</button>;
}

function B() {
  const [n] = useCounter();
  console.log("B", n);
  return <span>B</span>;
}

// both mounted, then A's button clicked once
Show the output
A 0
B 0
A 1

A custom hook shares logic, not state. Each call gets its own state cell in its own component, so A's click re-renders only A and B still reads 0 - and B does not even re-render, so nothing new is logged for it.

A hook inside a condition

function App({ showEmail }) {
  const [name, setName] = useState("");
  if (showEmail) {
    const [email, setEmail] = useState("");
  }
  const [age, setAge] = useState(0);
  return null;
}

// rendered with showEmail={true}, then with showEmail={false}
Show the output
Error: Rendered fewer hooks than expected. This may be caused by an accidental early return statement.

The first render calls three hooks and fills three cells; the second calls two, so React reaches the end of your component with a cell left over and throws. In the other direction - false first, then true - you get "Rendered more hooks than during the previous render". Either way, the fix is to call the hook unconditionally and branch inside it.

Reducer against the same three-clicks problem

function reducer(state, action) {
  if (action === "inc") return { count: state.count + 1 };
  return state;
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 });

  function bump() {
    dispatch("inc");
    dispatch("inc");
    dispatch("inc");
  }

  return <button onClick={bump}>{state.count}</button>;
}

// one click
Show the output
3

A reducer always receives the current state as its first argument, so three dispatches compose the way three functional updaters do. This is why a reducer is immune to the stale-closure family of bugs, and why dispatch is safe to call from inside an interval.

Reading state right after setting it

function App() {
  const [n, setN] = useState(0);

  function go() {
    setN(n + 1);
    console.log("after setN, n is", n);
  }

  return <button onClick={go}>{n}</button>;
}

// clicked once, then clicked again
Show the output
after setN, n is 0
after setN, n is 1

n is a const belonging to the render that created this handler; the setter cannot reach back and change it. The second click runs a handler from the second render, which closed over 1. Nothing here is asynchronous in the way people mean when they say "setState is async" - the value simply belongs to a different render.

Write it from memory

Complete, runnable code. Practice typing it from memory before your interview.

useFetch - loading, error, data, and abort on unmount

The hook a coding round asks for by name. Four things have to be visible in it: the loading flag resets on every url change, res.ok is checked because fetch does not reject on a 404, the abort is returned as cleanup, and an AbortError is swallowed rather than shown to the user.

import { useState, useEffect } from "react";

export function useFetch(url) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(Boolean(url));

  useEffect(() => {
    if (!url) return;

    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error("Request failed: " + res.status);
        return res.json();
      })
      .then((json) => {
        setData(json);
        setError(null);
      })
      .catch((err) => {
        if (err.name === "AbortError") return;
        setError(err);
        setData(null);
      })
      .finally(() => {
        if (!controller.signal.aborted) setLoading(false);
      });

    return () => controller.abort();
  }, [url]);

  return { data, error, loading };
}

// Using it
function User({ id }) {
  const { data, error, loading } = useFetch("/api/users/" + id);

  if (loading) return <p>Loading…</p>;
  if (error) return <p role="alert">{error.message}</p>;
  if (!data) return null;

  return <h1>{data.name}</h1>;
}
  • fetch only rejects on a network failure. A 404 or a 500 resolves normally, so without the res.ok check you set a page of error HTML as your data.
  • Aborting rejects the promise with an error whose name is AbortError. Ignore it - the request was cancelled on purpose and there is nobody to show it to.
  • The finally guard stops the loading flag flickering off for a request that was abandoned; the newer request has already set it to true.
  • Deliberately no options argument: an options object would be a new identity every render and the effect would loop. If you need one, take a stringified key or memoise it at the call site, and say so out loud.
  • Returning the abort as cleanup is what fixes the race - a slower earlier response can never land after a faster later one, because it was cancelled.

useDebounce - a value that lags behind

Delay a fast-changing value so an expensive consumer only sees it once the user stops. Twelve lines, and the cleanup is the whole trick.

import { useState, useEffect } from "react";

export function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);

  return debounced;
}

// Using it
function Search() {
  const [term, setTerm] = useState("");
  const debouncedTerm = useDebounce(term, 400);

  useEffect(() => {
    if (!debouncedTerm) return;
    console.log("searching for", debouncedTerm);
  }, [debouncedTerm]);

  return (
    <input
      value={term}
      onChange={(e) => setTerm(e.target.value)}
      placeholder="Search"
    />
  );
}
  • The cleanup clears the previous timeout, so every keystroke cancels the pending update and only the last one survives the delay. Without it you get one update per keystroke, just late.
  • The input stays bound to term, not to debouncedTerm - typing must feel instant. Only the expensive consumer reads the delayed value.
  • This debounces the VALUE. It does not cancel a request already in flight; that needs the AbortController from useFetch.
  • Throttle is the neighbour question: debounce fires once after the noise stops, throttle fires at most once per interval during it.

useLocalStorage - state that survives a refresh

Same signature as useState, backed by localStorage. Shows lazy initial state, JSON round-tripping, and defensive handling of a storage that can throw.

import { useState, useEffect, useCallback } from "react";

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const raw = window.localStorage.getItem(key);
      return raw === null ? initialValue : JSON.parse(raw);
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // quota exceeded, or storage blocked - keep the in-memory value
    }
  }, [key, value]);

  const remove = useCallback(() => {
    try {
      window.localStorage.removeItem(key);
    } catch {
      // ignore
    }
    setValue(initialValue);
  }, [key, initialValue]);

  return [value, setValue, remove];
}

// Using it
function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage("theme", "light");

  return (
    <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
      {theme}
    </button>
  );
}
  • The initialiser is a function, so localStorage is read once on mount rather than on every render.
  • Every access is wrapped: Safari's private mode has thrown on setItem, storage can be disabled, and a value written by an older version of your app can fail JSON.parse.
  • Because setValue is React's own setter, the functional form works: setValue(v => !v).
  • If it has to render on the server, guard on typeof window === "undefined" in the initialiser and return initialValue - otherwise hydration mismatches.
  • Two tabs will not sync. If asked, the answer is a storage event listener in another effect.

usePrevious - the value from the last render

Four lines, and it is really a question about when effects run. Interviewers use it to check that you know an effect runs AFTER the render that reads the ref.

import { useRef, useEffect } from "react";

export function usePrevious(value) {
  const ref = useRef(undefined);

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

// Using it
function Price({ amount }) {
  const previous = usePrevious(amount);
  const direction =
    previous === undefined ? "" : amount > previous ? "up" : "down";

  return (
    <p>
      {amount} <span>{direction}</span>
    </p>
  );
}
  • The order is: render returns ref.current (still the old value), React commits, then the effect overwrites it. So during render you always see one render behind.
  • It returns undefined on the first render - there is no previous value, and pretending otherwise is how a spurious "up" arrow gets shipped.
  • A ref, not state, because the previous value must not itself cause a render.

useToggle - the warm-up, with the trap handled

Trivial until someone passes it straight to onClick, at which point the toggle receives a click event as its argument. The guard is the interesting line.

import { useState, useCallback } from "react";

export function useToggle(initial = false) {
  const [on, setOn] = useState(initial);

  const toggle = useCallback((next) => {
    setOn((current) => (typeof next === "boolean" ? next : !current));
  }, []);

  return [on, toggle];
}

// Using it
function Panel() {
  const [open, toggle] = useToggle(false);

  return (
    <div>
      <button onClick={toggle}>{open ? "Hide" : "Show"}</button>
      <button onClick={() => toggle(false)}>Force closed</button>
      {open && <p>Contents</p>}
    </div>
  );
}
  • onClick={toggle} passes the event object as next. Without the typeof next === "boolean" check, a naive setOn(next) would set state to a React synthetic event and every truthiness check downstream would say open.
  • useCallback with [] keeps the identity stable - safe to pass to a memoised child, and safe as another hook's dependency.
  • The functional updater means the toggle is correct even when fired twice in one batch.
← REST, HTTP and GitForms: controlled, uncontrolled and validated →