skip to content

Write a React custom hook `useToggle(initial = false)` that returns a boolean and a function that flips it. Why should the flip call `setOn(v => !v)` instead of `setOn(!on)`?

level: juniorimportance: should knowfreq 52%

answer

  1. it is just useState in a wrapper
  2. the toggler captures a snapshot
  3. who computes the next value
  4. two flips in one batch
  5. empty dependency array is the tell

basics

~20 s

useToggle holds a boolean in useState and returns it with a toggler that calls setOn(v => !v). The updater form flips whatever the newest value is, so the toggle is still correct when the captured value is stale or two flips land in one event.

solid answer

~50 s

The hook is four lines: `const [on, setOn] = useState(initial)` and a `toggle` that calls `setOn(v => !v)`, returned together. The reason for the updater form is that `on` is a value captured by the render that created the toggler. `setOn(!on)` computes the next state from that snapshot, so if the toggler is called twice inside one event, or called later from a timer or a subscription that was set up in an earlier render, both calls compute the same result and one flip is lost. `setOn(v => !v)` hands React a function that React applies to the *pending* state, in queue order, so two flips cancel correctly and a stale capture cannot produce a wrong value. Wrapping `toggle` in `useCallback` with an empty dependency array is safe precisely because the updater form needs nothing from the render scope.

code

javascript · 9 lines
javascript
import { useState, useCallback } from 'react';

export function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn(v => !v), []);
  const open = useCallback(() => setOn(true), []);
  const close = useCallback(() => setOn(false), []);
  return { on, toggle, open, close };
}

go deeper

for a junior

Be able to type the four-line hook from memory and say that a custom hook is an ordinary function that calls other hooks. Know that the updater form flips the latest value rather than the one captured when the function was made.

for a middle

Explain the queue: React stores updater functions and applies them in order against the pending state, so two flips in one batch net out correctly while two snapshot-based calls collapse into one. Mention that the updater form is what makes an empty dependency array honest.

for a senior

Show the failure in production terms — a toggler handed to a timer, a subscription, or a memoized child keeps a frozen value forever. Be ready to say what you would review for in a PR and why a stable callback identity matters for downstream re-render cost.

for a principal

Own the convention question: which of these micro-hooks belong in a shared package at all, given each one is four lines and every misuse becomes a codebase-wide pattern. Discuss what the React Compiler changes about hand-written memoization and what it explicitly does not fix.

## The hook `useToggle` is the smallest useful custom hook and the one interviewers most often ask you to write on the spot, because it exercises three things at once: that a custom hook is just a function calling other hooks, that state updates are queued rather than applied immediately, and that a value read from a render is a snapshot. ```js import { useState, useCallback } from 'react'; function useToggle(initial = false) { const [on, setOn] = useState(initial); const toggle = useCallback(() => setOn(v => !v), []); return [on, toggle]; } ``` There is no magic in the `use` prefix beyond convention plus the lint rules that key off it. The hook has no special storage of its own: it calls `useState`, and React associates that state with the *component* that called the hook, in call order. ## Why the updater form, concretely Every render of a component produces a fresh set of local constants. Inside the render that produced `on === false`, the closure `() => setOn(!on)` is permanently a closure that computes `setOn(true)` — it does not re-read state later. That is fine in the ordinary case, because a click causes a render and the next click closes over the new value. It stops being fine in three real situations: 1. **Two flips in one event.** A handler that opens a menu and another that closes it both firing in the same click will both compute from the same snapshot. Since React 18, *all* updates batch — not only those inside event handlers — so both queued updates are applied before the next render, and `setOn(true); setOn(true)` collapses to one flip instead of two. 2. **A toggler captured by something long-lived.** If the toggler is handed to `setTimeout`, to `element.addEventListener` inside an effect that ran once, or stored in a ref, it keeps the value from the render where it was created forever. `!on` is frozen; `v => !v` is not. 3. **Callers who cannot see the current value.** A parent that only receives `toggle` has no way to pass the correct next state; the updater form makes that unnecessary. With the updater, React stores the function in the update queue for that state slot and calls it with the state as it stands after the preceding queued updates. Order is preserved, so N flips in one batch produce N flips. ```js // inside one click handler setOn(!on); // queued: -> true, then queued: -> true (net: on) setOn(!on); setOn(v => !v); // queued: false -> true, then true -> false (net: off) setOn(v => !v); ``` ## Stability of the returned function Because `v => !v` reads nothing from the enclosing render, the dependency array of `useCallback` is genuinely empty and the `toggle` identity stays the same for the component's whole lifetime. That matters when `toggle` is passed to a memoized child or used as an effect dependency: a toggler written as `() => setOn(!on)` would need `on` in its dependency list, would change identity on every flip, and would re-run anything that depends on it. The updater form is what makes the stable-identity version *correct*, not just shorter. (With the React Compiler enabled the memoization is inserted for you, but the correctness argument for the updater form is unchanged — the compiler does not fix a stale read.) ## Sensible variants Real codebases usually want more than a blind flip, because a menu or dialog often needs an explicit close: ```js function useToggle(initial = false) { const [on, setOn] = useState(initial); const toggle = useCallback(() => setOn(v => !v), []); const open = useCallback(() => setOn(true), []); const close = useCallback(() => setOn(false), []); return { on, toggle, open, close }; } ``` A lazy initial value is worth knowing about too: if computing the initial boolean is expensive, `useState(() => expensive())` runs the function only on the first render, whereas `useState(expensive())` calls it on every render and throws the result away. ## What it does not do `useToggle` does not give you a toggle that survives unmount, and it does not synchronise anything across components — each call site gets its own independent `useState` slot. If two distant components must see the same flag, the state has to live somewhere both can reach; the hook is a way to reuse the *logic*, and reusing it twice creates two independent booleans.

  • If `toggle` never changes identity, does that mean the component skips re-rendering when it is called?
    No. A stable function identity only prevents *downstream* work — a memoized child receiving `toggle` as a prop will not re-render because of that prop. Calling `toggle` still queues a state update on the component that owns the `useState`, so that component and its subtree render as usual unless something else bails them out.
  • Two sibling components both call `useToggle(false)`. Do they share the flag?
    No. Each call site gets its own `useState` slot on its own component instance, so they flip independently. A custom hook packages behaviour, not a shared value; if both must reflect one flag, the state has to be owned by a common ancestor or another shared holder and passed down.
  • When is `setOn(!on)` actually fine?
    When the toggler is created fresh each render, is called at most once per event, and is invoked from JSX during the current render's lifetime — the ordinary button-onClick case. It is fine but not better, and it becomes wrong the moment someone stores the callback, adds a second call, or memoizes it with a stale dependency list, so the updater form is the default worth having.

saying these in an interview costs you the question

  • Claims the custom hook gives its callers shared state
  • Says setOn(!on) and setOn(v => !v) are always equivalent
  • Thinks state updates apply synchronously right after the setter returns
  • Believes batching only happens inside React event handlers
  • Adds `on` to useCallback deps and calls the result a stable callback

context