Testing Hooks
Custom hooks bundle reusable stateful logic, but you can’t call them directly inside a test—hooks only run inside a React render. React Testing Library solves this with renderHook, a helper that mounts a throwaway component, runs your hook inside it, and hands back the latest return value plus controls to trigger updates. This page covers asserting on returned values, dispatching state changes through act, rerendering with new arguments, and the cases where you should skip renderHook entirely and test the hook through a real component.
Why hooks need a special harness
A hook like useCounter calls useState, which only works while React is rendering. If you call it in a plain test function React throws “Invalid hook call.” renderHook wraps the call in a tiny host component so the rules of hooks are satisfied, then exposes a result object whose .current property always points at the most recent return value.
// useCounter.js
import { useState, useCallback } from "react";
export function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = useCallback(() => setCount((c) => c + 1), []);
const reset = useCallback(() => setCount(initial), [initial]);
return { count, increment, reset };
}
Asserting on returned values
renderHook returns { result, rerender, unmount }. Read the current snapshot from result.current. Because the value is the hook’s return on the latest render, you assert against it just like any object.
// useCounter.test.js
import { renderHook } from "@testing-library/react";
import { expect, test } from "vitest";
import { useCounter } from "./useCounter";
test("starts at the provided initial value", () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
});
Always read
result.currentfresh in each assertion. Don’t destructureconst { count } = result.currentonce and reusecountafter an update—it captures a stale snapshot and your test will assert against the old value.
Triggering updates with act
Calling a setter or returned callback schedules a state update. React batches and flushes those updates inside act, which guarantees the DOM and result.current reflect the change before your next assertion runs. RTL re-exports act; wrap every interaction that changes state in it.
import { act, renderHook } from "@testing-library/react";
import { expect, test } from "vitest";
import { useCounter } from "./useCounter";
test("increments the count", () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
If you forget act, React prints a warning (“An update to TestComponent inside a test was not wrapped in act(…)”) and result.current may still hold the pre-update value.
Rerendering with new props
To test how a hook reacts to changed arguments, pass an initialProps and use the callback’s parameter. The rerender function mounts the same hook again with new props—exactly what happens when a parent passes a new prop to a component using the hook.
import { renderHook } from "@testing-library/react";
import { expect, test } from "vitest";
import { useCounter } from "./useCounter";
test("reset uses the latest initial value", () => {
const { result, rerender } = renderHook(
({ start }) => useCounter(start),
{ initialProps: { start: 0 } },
);
rerender({ start: 10 });
act(() => result.current.reset());
expect(result.current.count).toBe(10);
});
Async hooks and waitFor
Hooks that fetch data update state after a promise resolves. Use waitFor to poll result.current until the expected value appears, rather than guessing at timing.
import { renderHook, waitFor } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { useUser } from "./useUser";
test("loads the user", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ name: "Ada" })),
);
const { result } = renderHook(() => useUser(1));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.user.name).toBe("Ada");
});
Output:
✓ useUser.test.js (1)
✓ loads the user
Test Files 1 passed (1)
Tests 1 passed (1)
Providing context to a hook
If your hook reads from a context, supply a wrapper—a component that renders the provider around children.
import { renderHook } from "@testing-library/react";
import { ThemeProvider } from "./ThemeProvider";
import { useTheme } from "./useTheme";
const wrapper = ({ children }) => (
<ThemeProvider value="dark">{children}</ThemeProvider>
);
test("reads the theme from context", () => {
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current).toBe("dark");
});
When to test through a component instead
renderHook is ideal for genuinely standalone, reusable hooks (a useDebounce, a useLocalStorage). But if a hook is tightly coupled to one component’s UI—or you find yourself reaching into result.current to simulate every interaction—you’re testing implementation details. In those cases render the real component and assert on what the user sees.
Use renderHook | Test through a component |
|---|---|
| Shared, generic hook with no UI | Hook used by exactly one component |
| Logic-heavy with branching state | Behavior is easiest to express as user actions |
| You publish the hook as an API | The hook’s output only matters via rendered output |
Best Practices
- Read
result.currentfresh in every assertion; never cache its destructured fields across an update. - Wrap any call that changes state—setters, callbacks, timers—in
actto flush updates deterministically. - Use
waitForfor asynchronous hooks instead of arbitrary timeouts or manual delays. - Drive argument changes through
rerenderwithinitialPropsrather than remounting from scratch. - Supply context and providers via the
wrapperoption so the hook runs in a realistic tree. - Reserve
renderHookfor reusable hooks; test single-use, UI-bound hooks through their component. - Mock external dependencies (network, storage) at the boundary so the hook’s logic stays the unit under test.