open atlas
↑ Back to track
React, zero to senior RCT · 07 · 03

Testing hooks and context: renderHook, fake timers, stale snapshots

renderHook mounts a throwaway host component around your hook; result.current is a live property — re-read it after every act, a destructured copy is one render frozen. wrapper injects providers. Fake timers deadlock user-event unless setup wires advanceTimers.

RCT Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The autosave migration was supposed to take an afternoon: switch the useAutosave hook tests from real 800 ms debounce waits to fake timers, shave four minutes off the suite. Instead, the first converted test hung the CI job until the ten-minute kill signal. Locally it hung too — no error, no timeout message at first, just a frozen await user.type(editor, "draft"). The engineer bisected for most of a day: not the hook, not MSW, not the React version. The deadlock was structural: vi.useFakeTimers() patches setTimeout globally, and user-event schedules its inter-keystroke delays through exactly that patched setTimeout — then awaits a timer that no one will ever advance, because the test is itself awaiting user-event. Frozen clock waits for the test; test waits for the frozen clock. The fix was one line — userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) — documented, known, and still re-discovered the hard way by team after team. The same migration surfaced a second classic: an assertion on a destructured const { current } = result that kept reading the pre-debounce value forever, because result.current is a live property and a destructured copy is a snapshot of one render. Two bugs, zero product code involved — both were bugs in the tests’ model of time and identity.

What renderHook actually does

Hooks cannot run outside a component — the Rules of React are physics, not style. renderHook (shipped in @testing-library/react) makes the smallest legal physics lab: it mounts a throwaway host component, calls your hook inside it with initialProps, and copies the hook’s return value onto result.current after every committed render. Nothing else is special — which is exactly why its API is shaped the way it is:

const { result, rerender, unmount } = renderHook(
  ({ userId }) => usePermissions(userId),
  {
    initialProps: { userId: "u1" },
    wrapper: ({ children }) => (
      <PermissionsProvider value={stubPermissions}>{children}</PermissionsProvider>
    ),
  },
);

rerender({ userId: "u2" });           // re-renders the host with new props
expect(result.current.canEdit).toBe(false);
unmount();                            // runs effect cleanups — test them too

wrapper is the provider injection point, and it carries a design decision: pass a stub provider value — a plain object implementing the context’s contract — rather than vi.mock-ing the provider module. A stub value exercises the real useContext wiring and survives provider refactors; a module mock couples the test to file paths and export shapes. For context like a query client, the rule has a sharper edge: create a fresh client per test inside the wrapper, or cached data leaks between tests and manufactures order dependence.

When should a hook get its own test at all? Test the hook directly when it is the public contract — a shared usePagination consumed by twelve teams deserves contract tests independent of any one consumer. When the hook exists to serve one component, test the component: a hook test can stay green while the component integration is broken (wrong prop threading, render timing), and a senior suite spends its budget where the user-visible contract lives.

Quiz

A test does: const { current } = result; await act(async () => result.current.save()); expect(current.status).toBe('saved') — and fails with status 'idle'. The hook works in production. What is wrong?

Reading result.current: identity, not timing

result.current is a property the harness reassigns after every commit. When you see an assertion that destructures result at the top of a test and reads from it later, flag it immediately — it is the most common silent bug in hook test suites. Three consequences, each a code-review checkpoint. First — never destructure: const { current } = result (or const value = result.current saved for later) freezes one render’s output; every assertion after the next state update reads history. The correct idiom is boringly repetitive: result.current.x at every read site, because each read must observe the latest commit. Second — updates need act: calling result.current.save() outside act fires the state update without flushing it deterministically (and warns); wrap state-changing calls, await async ones. Third — after unmount(), result.current keeps the last committed value, which is useful for asserting final state but means “no error on read” does not prove the hook is still alive; what unmount actually verifies is cleanup — intervals cleared, subscriptions closed, pending debounce flushed or cancelled, whichever your contract promises.

Fake timers, debounce, and the user-event deadlock

A debounced or interval hook tested against the real clock costs real seconds per test and still flakes under CI load. Fake timers make time a controlled input: vi.useFakeTimers() swaps the global timer functions for a virtual clock, and vi.advanceTimersByTime(800) fires exactly the callbacks due in that window — deterministic, instant, load-independent. The discipline around them:

it("saves once after 800 ms of silence", async () => {
  vi.useFakeTimers();
  const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  render(<Editor />);

  await user.type(screen.getByRole("textbox"), "draft");

  act(() => { vi.advanceTimersByTime(799); });
  expect(saveSpy).not.toHaveBeenCalled();      // boundary: nothing at 799

  act(() => { vi.advanceTimersByTime(1); });
  expect(saveSpy).toHaveBeenCalledTimes(1);    // fires exactly at 800

  vi.useRealTimers();                          // always restore — afterEach is safer
});

The advanceTimers option is the deadlock breaker from the Hook: user-event paces its event sequences through setTimeout, so under a frozen fake clock every await user.type(...) waits forever unless user-event is told how to advance the clock itself. Wire advanceTimers: vi.advanceTimersByTime at setup time — every team that adopts fake timers without it loses a day to a hung test. The boundary assertion pattern (799 then 1) is what makes a debounce test worth having: it pins the contract — fires once, fires at the deadline, does not fire early — rather than “called eventually”. Two honest caveats keep this technique from biting back. Fake timers fake timers, not microtasks: resolved-promise continuations still run on the real microtask queue, so advanceTimersByTime inside act plus an await afterward is the shape that lets both queues drain. And a leaked fake clock is a suite-wide contaminant — restore in afterEach(() => vi.useRealTimers()), because the next test’s waitFor polls on the very setTimeout you froze, and its 1-second budget never starts ticking.

Quiz

After vi.useFakeTimers(), a test hangs forever at: await user.type(input, 'abc'). No assertion has run yet. What is the mechanism?

Why this works

Why does user-event schedule timers at all — would zero-delay events not be simpler? The delays are not decoration. Between the events of a sequence, user-event yields control so React can process each event the way a browser would: keydown commits state before keyup fires, focus effects run before the click lands. Collapse that spacing and you manufacture event interleavings no browser produces — input handlers observing un-flushed state from the previous keystroke, exactly the class of false signal user-event exists to eliminate. The yield is implemented with setTimeout, which is why the fake-timer interaction is structural rather than incidental: any faithful event simulator must wait, and anything that waits on a frozen clock deadlocks unless it is handed the crank. advanceTimers is that crank — user-event calls it instead of waiting, so simulated pacing and virtual time advance together.

Recall before you leave
  1. 01
    Explain what renderHook builds, the semantics of result.current, and the two ways tests misread it.
  2. 02
    Walk through the fake-timer discipline for a debounced hook, including the user-event deadlock and the microtask caveat.
Recap

Hook testing is two disciplines stacked: identity and time. The identity half starts with what renderHook actually is — a minimal host component that calls your hook and reassigns result.current after every commit. That makes result.current a live property, and every misuse follows from forgetting it: a destructured const current is one render frozen and reads history forever; a state-changing call outside act fires an unflushed update; a post-unmount read shows the last committed value, which proves cleanup ran, not that the hook lives. The wrapper option injects context, and the senior choice there is a stub provider value over a module mock — it exercises the real useContext path and survives provider refactors — plus a fresh instance per test for anything stateful, or cached data manufactures order dependence. Test hooks directly only when the hook is the shared public contract; a single-consumer hook is better covered through its component, where the user-visible integration lives. The time half: fake timers turn the clock into a controlled input — advanceTimersByTime fires exactly what is due, so a debounce test asserts its true contract at the boundary (nothing at 799 ms, exactly one call at 800) instantly and deterministically. The structural trap is the user-event deadlock from the Hook: user-event paces real event sequences through setTimeout, a frozen clock never fires them, and the test awaits user-event while user-event awaits the clock — broken only by wiring advanceTimers: vi.advanceTimersByTime into setup. Two caveats keep the technique honest: microtasks are not faked, so advance-then-await lets both queues drain; and a fake clock leaked past afterEach freezes the next test’s waitFor budget, producing flakes that look unrelated. Both halves reward the same habit — model precisely what the harness controls, and the hung tests and stale reads disappear. Now when you see vi.useFakeTimers() in a test file, your first reflex is to check whether advanceTimers is wired into userEvent.setup — and when you see a destructured const { current } = result, you flag it before the PR merges.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.