skip to content

Using React Testing Library's `renderHook`, how do you test that a custom hook such as `useDebouncedValue(value, delay)` behaves correctly when its input argument changes, rather than only on first render?

level: middleimportance: should knowfreq 44%

answer

  1. behaviour lives in the transition
  2. initialProps feeds the callback
  3. rerender keeps the same instance
  4. replace the whole props object
  5. fake clock, never a real sleep

basics

~20 s

Give renderHook an initialProps object, write the callback to take those props, then call the returned rerender with new props. That re-invokes the hook with different arguments in the same mounted instance, which is the only way to observe behaviour that depends on a change between renders.

solid answer

~40 s

The hook's interesting behaviour is a transition, not a value, so a single render can never show it. `renderHook` takes an `initialProps` option and passes it to your callback, and the returned `rerender(newProps)` re-invokes that callback with different arguments while keeping the same mounted hook instance — which is what preserves the state and effects the transition depends on. So you render with `{ value: 'a' }`, rerender with `{ value: 'b' }`, and assert on how the returned value moves: unchanged immediately, then updated once the debounce window elapses, using your runner's fake clock rather than a real wait. The general principle matters more than the API: anything about memoization, effect re-runs, or a reset on changed inputs is only testable if your harness can drive a second render with different props.

code

javascript · 19 lines
javascript
import { renderHook, act } from '@testing-library/react';
import { useDebouncedValue } from './useDebouncedValue';

test('holds the previous value until the delay elapses', () => {
  jest.useFakeTimers();

  const { result, rerender } = renderHook(
    ({ value, delay }) => useDebouncedValue(value, delay),
    { initialProps: { value: 'a', delay: 200 } },
  );

  rerender({ value: 'b', delay: 200 });
  expect(result.current).toBe('a');

  act(() => jest.advanceTimersByTime(200));
  expect(result.current).toBe('b');

  jest.useRealTimers();
});

go deeper

for a junior

Know that the hook test helper can render again with different arguments, and that you pass the starting arguments in as options rather than hardcoding them inside the callback.

for a middle

Explain that the hook's behaviour is a transition between renders, and walk through initialProps plus rerender keeping the same mounted instance so state and effects survive.

for a senior

Show which assertions actually carry information — the value has not changed yet, rapid changes collapse into one update — and insist on a controlled clock rather than real waiting so the suite stays fast and deterministic.

for a principal

Own the harness question: a hook-testing approach that cannot drive a second render with new inputs can only cover first-render values, and you should be able to say what the fallback host-component approach costs a team.

## Why one render is not enough Many custom hooks are not really functions of their current arguments — they are functions of how those arguments *changed*. A debounce hook is defined entirely by what happens between the moment the input changes and the moment the delay elapses. A memoizing hook is defined by what it does *not* recompute when the input is equal. A hook that resets internal state when an id prop changes has no observable behaviour at all until the id changes. Call such a hook once and every one of those properties is invisible. The test would assert that the initial value comes back, which is true of a hook with the logic deleted. ## The mechanism `renderHook` accepts an options object with `initialProps`, and passes that object as the argument to your render callback. The returned `rerender` calls the callback again with whatever you hand it, re-rendering the same stub component so the hook instance, its state and its effects survive: ```jsx const { result, rerender } = renderHook( ({ value, delay }) => useDebouncedValue(value, delay), { initialProps: { value: 'a', delay: 200 } }, ); expect(result.current).toBe('a'); rerender({ value: 'b', delay: 200 }); expect(result.current).toBe('a'); // not yet — this is the actual claim ``` Two details are load-bearing. First, the callback must *read* its argument; a callback that closes over a test-local variable will keep returning the same thing no matter what you pass to `rerender`, and the test will pass for the wrong reason. Second, `rerender` must be given the whole props object, because it replaces the argument rather than merging into it — omit `delay` and the hook receives `undefined`. ## Driving time without waiting A debounce test needs the clock to move. Use your runner's fake-timer facility to install a controllable clock before rendering and advance it explicitly after the rerender, then assert the value has caught up. Never write a real sleep long enough to "probably" cover the delay: it makes the suite slow in proportion to every timeout in the codebase, and it is flaky in exactly the way that matters, since a loaded CI machine is where the margin disappears. Do remember to wrap the clock advance so the resulting state update is flushed before you read the value. ## The assertions that make the test worth writing A weak version of this test only checks the end state — after the change and the delay, the value is `'b'`. That passes for a hook with no debounce at all. The assertions that carry information are the negative ones and the collapsing one: - immediately after the change, the returned value is still the old one; - after a partial advance, still the old one; - after the full delay, the new one; - and, if you rerender three times in quick succession, only one final update lands rather than three. That last case is the whole reason the hook exists, and it is only reachable through repeated rerenders. ## The same shape for other hooks The pattern generalises well beyond debouncing: - **Reset-on-key hooks.** Render with `{ userId: 1 }`, mutate some internal state through the hook's own API, rerender with `{ userId: 2 }`, and assert the state went back to its initial value. - **Memoization.** Rerender with an equal-but-not-identical object and assert the returned reference is the same one, which is the property consumers depend on. - **Cleanup between changes.** Rerender with a new subscription target and assert the old subscription was torn down — usually by observing a spy on the thing being subscribed to, not by counting effect invocations. ## The transferable point If you carry one thing out of this into a different tool, it is that a hook harness must let you drive a *second* render of the *same* instance with *different* inputs. Any harness lacking that can only test first-render values, which is the least interesting half of most hooks. When a helper does not offer it, the fallback is the honest one: render a small host component that holds the changing input in state and change it the way a real parent would.

  • Why does the render callback have to read its argument rather than close over a variable in the test?
    Because rerender only changes what it passes into the callback. A callback that ignores its parameter and reads a test-local variable will return the same thing on every render, so the test passes whether or not the hook reacts to changed inputs — the worst kind of green. Take the props as a parameter and destructure them inside the callback.
  • What does a debounce test still miss that only a component test would catch?
    Whether anything actually feeds the hook a changing value. A rerender with new props is the test playing the role of the parent; a real component might pass a value that never changes, recreate the hook on every keystroke by remounting, or ignore the debounced output entirely. One component test that types into the input covers the wiring the hook test assumes.
  • How would you test that a hook resets its internal state when a key prop changes?
    Render with the first key, drive the hook's own API to move its state away from the initial value, then rerender with a different key and assert the state is back to the initial value. Add the negative case too — rerender with the same key and assert the state survived — otherwise a hook that resets unconditionally would pass.

saying these in an interview costs you the question

  • A single render is enough to test a debounce hook
  • Asserting only the final value proves the delay works
  • Waiting with a real timeout is fine if it is short
  • rerender merges into the previous props object
  • Closing over a test variable is the same as passing props

context