skip to content

In a React Testing Library `renderHook` test, a value destructured out of `result.current` before an update still shows the old number when you assert afterwards. Why, and what do you write instead?

level: juniorimportance: should knowfreq 48%

answer

  1. result is a box, current is the slot
  2. each render replaces the return value
  3. destructuring copies, it does not track
  4. read through result.current in every assertion
  5. saved callbacks close over an old render

basics

~20 s

renderHook stores each render's return value in result.current and replaces it after every re-render. Destructuring copies the value at that instant, so the local variable is frozen at the old render. Read result.current fresh inside every assertion.

solid answer

~40 s

`result` is a stable container object that the test helper keeps a reference to; `result.current` is the pointer it overwrites with whatever the hook returned on the most recent render. When you write `const { count } = result.current`, you copy the number out of the old return value, and nothing will ever update that copy — the helper reassigns `.current`, it does not mutate the object you destructured. So the fix is to keep the indirection all the way into the assertion: `expect(result.current.count).toBe(1)`, and call actions as `result.current.increment()` rather than through a saved reference. This is not a quirk of one library; any hook-testing helper needs a mutable box, because the only way to observe a value that changes across renders is to look it up again after the render happens.

code

javascript · 18 lines
javascript
import { useState } from 'react';
import { renderHook, act } from '@testing-library/react';

function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  const increment = () => setCount((c) => c + 1);
  return { count, increment };
}

test('increment updates the returned count', () => {
  const { result } = renderHook(() => useCounter(0));
  const { count: copied } = result.current;

  act(() => result.current.increment());

  expect(copied).toBe(0);
  expect(result.current.count).toBe(1);
});

go deeper

for a junior

Recall the concrete rule: always write result.current.something inside the assertion instead of pulling values out into local variables first. Say plainly that each render replaces the stored return value.

for a middle

Explain the mechanism — the helper reassigns .current on every render, so a destructured copy belongs to a render that no longer exists — and connect it to why state is a new value per render rather than a mutated one.

for a senior

Distinguish this failure from an unflushed or not-yet-arrived render, since both surface as an unexpectedly old value, and explain why calling a saved callback can pass while testing behaviour no real component would ever see.

for a principal

Frame it as an API-shape tradeoff: an observation box is the only way a straight-line test can watch a multi-render process, and note the review guidance that keeps a team's hook tests from encoding stale snapshots.

## The mechanism A hook returns a fresh value on every render. A test, however, runs as one straight-line function. Bridging those two requires a mutable box that the test can look inside after each render — that box is `result`, and the box's slot is `result.current`. The helper does roughly this on every render of its stub component: ```js result.current = callback(); // the hook's newest return value ``` That is an assignment to a property, not a mutation of the previous value. The object that was in `.current` a moment ago is untouched and now unreferenced by the helper. Anything you copied out of it — a number, a string, a function reference — belongs to a render that has already been superseded. ```js const { count } = result.current; // count === 0, forever act(() => result.current.increment()); count; // still 0 — a copy of the old render's value result.current.count; // 1 — the current render's value ``` ## Why this trips people up The same pattern would work in most other testing contexts. If you get an object back from a normal function and it is later mutated in place, your destructured reference is not stale, because you would have held the same object. Hook returns are different by design: state is immutable per render, and each render produces a *new* return value rather than editing the old one. That is the same rule that produces stale-closure bugs in application code, showing up in test code. The result is a class of test failure that reads like a framework bug — "the hook clearly increments, but my assertion says zero" — when in fact the assertion is looking at a snapshot from before the update. ## The habit that avoids it Keep the full path in the assertion: ```js expect(result.current.count).toBe(1); expect(result.current.status).toBe('ready'); ``` And call actions through the box too: ```js act(() => result.current.setStatus('loading')); ``` Calling a *saved* action reference often appears to work, because the state setters the framework hands you keep a stable identity across renders. That is a trap: as soon as the hook returns a hand-written callback that closes over its own state — `const submit = () => save(draft)` — a saved reference will run against an outdated `draft`, and the test will assert against behaviour no real consumer would ever get, since a real consumer re-reads the hook's return every render. Always going through `result.current` makes the test match what a component actually does. ## Related pitfalls in the same family - **Snapshotting the whole return object.** `const api = result.current` has the same defect as destructuring one field; the object itself is the stale thing. - **Asserting before the update flushes.** If you read `result.current` before the state update has been applied, you see the pre-update render for a different reason — the render has not happened yet. Both mistakes produce the same wrong number, so check which one you have: is the value stale because you copied it, or because nothing re-rendered? - **Holding on across a rerender with new arguments.** Re-invoking the hook with different inputs produces yet another return value; every previously captured reference is stale after it. ## Why the API is shaped this way It would be nicer if the helper simply returned the hook's value, but a returned value cannot change after the fact. The box exists so a synchronous test body can observe an inherently multi-render process. Once you see `result` as a live window rather than a value, the rule stops feeling arbitrary: read through the window every time you want to know what the hook says *now*.

  • Is it safe to save a callback returned by the hook and call it later in the test?
    Usually not. Framework-provided state setters have a stable identity so a saved reference happens to work, but a hand-written callback returned by the hook closes over the state of the render that produced it. Calling the saved copy exercises stale data that no real consumer would ever use, because a component re-reads the hook's return on every render. Call through result.current.
  • My assertion reads result.current directly and still sees the old value. What else could be wrong?
    Then the re-render has not happened yet rather than the value being a copy. Either the state update was not wrapped so the framework could flush it, or the update is asynchronous and the test needs to wait for the next render before reading. The distinguishing question is whether a new render occurred at all.

saying these in an interview costs you the question

  • result.current is mutated in place, so copies stay fresh
  • Destructuring the hook's return is fine if you do it late
  • The old value means the hook itself is broken
  • Saved callbacks from result.current are always safe to reuse

context