Topic 17.6
Testing Hooks, Context and Reducers
In one line
Test custom hooks with renderHook and act, give components and hooks the providers they need through a wrapper (usually inside a custom render helper), and test reducers as plain functions with no React at all.
Think of it like this
Testing a car's engine on a test stand. A mechanic doesn't need the whole car to check the engine: they bolt it to a stand that supplies fuel and power, start it, and read the dials. **renderHook is that test stand for a custom hook: it supplies a tiny component to run the hook in. A wrapper** is the fuel line: the context providers the hook expects to find around it.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- renderHook
- A Testing Library function that runs a custom hook inside a hidden test component.
- result.current
- What the hook returned on its latest render. Read it fresh after every change.
- act()
- Runs code that updates state and applies the resulting renders before the next line.
- initialProps / rerender
- The first arguments for the hook, and a way to render it again with new ones.
- wrapper
- A component that wraps what you render, used to supply providers like context, router or store.
- Custom render
- Your own helper around
renderthat adds the app's providers, so every test doesn't repeat them. - Reducer
- A pure function that takes the current state and an action and returns the next state.
- Fresh store
- A new store instance per test, so no state leaks between tests.
Step by step
01Decide: test the hook directly or through a component?
If the hook exists only to serve one component, test that component; the hook gets covered on the way. If the hook is shared or published, test its API with renderHook too.
02Run the hook and read result.current
Call the hook's functions inside act, then read result.current again for the new values.
const { result } = renderHook(() => useCounter({ max: 10 }));
act(() => result.current.increment());
expect(result.current.count).toBe(1);03Change the arguments with rerender
initialProps is passed to your callback; rerender(newProps) calls the hook again with new ones, just like a parent re-rendering with new props.
const { result, rerender } = renderHook((props) => useCounter(props), {
initialProps: { max: 6 },
});
rerender({ max: 8 });04Supply providers with a wrapper and a custom render
Write the helper once in test/utils.jsx. It accepts options for what varies (the user, the route, the preloaded store) and returns everything render returns, plus the store if tests need it.
export function renderWithProviders(ui, { user = null, route = "/", store = makeStore() } = {}) {
function Wrapper({ children }) {
return (
<Provider store={store}>
<MemoryRouter initialEntries={[route]}>
<AuthProvider initialUser={user}>{children}</AuthProvider>
</MemoryRouter>
</Provider>
);
}
return { store, ...render(ui, { wrapper: Wrapper }) };
}05Test reducers with no React at all
Arrange a state, act by calling the reducer, assert on the returned state. Also check the input wasn't changed: reducers must be pure.
test("toggled flips one todo", () => {
const before = [{ id: 1, text: "Milk", done: false }];
const after = todosReducer(before, { type: "toggled", id: 1 });
expect(after).toEqual([{ id: 1, text: "Milk", done: true }]);
expect(before[0].done).toBe(false);
});Try it yourself
- 1
Fix the stale test
In the renderHook example, change the last assertion to
expect(result.current.count).toBe(1). Predict the output. Then remove theact(() => ...)wrapper aroundincrement(): what happens in the preview? (In a Vitest setup withIS_REACT_ACT_ENVIRONMENTon, you'd also see an act warning.) - 2
Test a min boundary
Add a test that
useCounter({ initial: 0, min: 0 })stays at 0 afterdecrement(). Predict, then run. - 3
Add a reducer test
In the reducer example, write a test for
cleared: given one done and one not-done todo, only the not-done one remains.
Code & diagrams
The last test fails on purpose: `count` was copied out of the first render's result, so it stays 0. Read `result.current.count` after the update instead. Note that `initial` changing on rerender doesn't reset the count: useState only uses its initial value once.
Preview
Console
Nothing logged yet.Expected console output
✓ starts at the initial value
✓ increment and decrement update the count
✓ never goes above max
✓ rerender passes new props to the hook
✗ stale result: reading a saved copy instead of result.current: expected 1, got 0Each test says only what is special (signed in or not); the helper supplies the provider. `<AuthContext value>` is the React 19 way to provide context (Topic 9.2).
Preview
Console
Nothing logged yet.Expected console output
✓ signed out: shows a log in link
✓ useAuth reads the user from the provider
✓ useAuth outside a provider throws a clear error
✓ signed in: logging out swaps the menuReducer tests need no rendering at all. For the Redux component, the test builds a real store with preloaded state, so it checks the slice, the selector and the dispatch wiring together.
Preview
Console
Nothing logged yet.Expected console output
✓ added appends a new, not-done todo
✓ toggled flips one todo and doesn't mutate the old state
✓ unknown actions throw
✓ slice reducer adds the payload
✓ component + real store: clicking adds ten// test/utils.jsx
import { render, renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
function makeWrapper() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } }, // errors show at once instead of after 3 retries
});
return ({ children }) => <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
export const renderWithQuery = (ui) => render(ui, { wrapper: makeWrapper() });
// useUser.test.jsx (the API is mocked with MSW, Topic 17.5)
test("useUser loads the user", async () => {
const { result } = renderHook(() => useUser(7), { wrapper: makeWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data.name).toBe("Asha Rao");
});Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Render a context consumer without its provider
In the context example, call render(<AccountMenu />) instead of renderWithAuth(<AccountMenu />).
Break #2
Share one store between tests
Create const store = makeStore() once at the top of the test file and use it in every test.
Myth vs fact
Myth
Every custom hook needs its own renderHook tests.
Fact
Hooks used by one component are covered by that component's tests. Test hooks directly when they are shared, published or have tricky logic that's awkward to reach through the UI.
Myth
@testing-library/react-hooks is the package for testing hooks.
Fact
That package is deprecated. renderHook has been part of @testing-library/react since version 13.1, and works with React 18 and 19.
Myth
Mock useSelector (or the context) to test a component in isolation.
Fact
Mocking hides wiring bugs (wrong selector path, missing dispatch). Use a real store or provider with preloaded state; it's barely more code and tests real behaviour.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
renderHook'sresult.currentis updated by a test component that assigns the hook's return value on every render. That is why destructuring loses updates, and why, in StrictMode or concurrent rendering,result.currentalways reflects the last committed render. - ▸
RTL's
renderandrenderHookacceptwrapperpluscontainer,baseElement,hydrateand, in RTL 16,reactStrictMode(render inside<StrictMode>), which you can also set globally withconfigure({ reactStrictMode: true })to catch impure components in tests. - ▸
Testing reducers separately pairs well with the reducer + context pattern (Topic 9.6): the reducer tests cover every state transition quickly, and a few integration tests through the provider check the wiring. The same split works for Zustand (test the store's actions with
useStore.getState()outside React) and for state machines. - ▸
For Redux Toolkit, the official recommendation is integration tests with a real store built by a
setupStore(preloadedState)function and arenderWithProvidershelper that returns the store too, so a test can assert on dispatched results or preload state without mocking.
Remember this
- 1
Most custom hooks (Phase 10) are best tested through a component that uses them, with the same render-and-click tests as before: that is behaviour a user sees. But reusable hooks shared across an app or published as a library have their own API (what you pass in, what you get back), and the developer calling them is their user. For those,
renderHook(() => useCounter({ max: 10 }))from@testing-library/reactrenders a hidden test component that calls your hook and returns{ result, rerender, unmount }. - 2
result.currentis the hook's return value from the latest render. Read it after every change, never keep a copy:const { count } = result.currentis a snapshot that won't update (one of the live tests fails on purpose to show this). When you call a function the hook returned that updates state, wrap it in **act(() => ...)** (imported from@testing-library/react, which re-exports React'sact), so React applies the update before your next line readsresult.current. user-event andrenderdo this for you; direct calls on a hook's functions don't. - 3
renderHook(callback, { initialProps })passesinitialPropsto the callback, andrerender(newProps)renders again with new ones, which is how you test a hook's reaction to changing arguments.unmount()removes the test component so you can check cleanup (a subscription removed, a timer cleared). An async hook (one that loads data) is tested withawait waitFor(() => expect(result.current.status).toBe("done")). - 4
Components and hooks that read context (Phase 9) need a provider above them, or
useContextreturns the default value.render(ui, { wrapper: Wrapper })andrenderHook(fn, { wrapper })wrap the tree in a component you give, such as({ children }) => <AuthProvider initialUser={user}>{children}</AuthProvider>. Real apps have several providers (router, theme, auth, query client, store), so teams write one custom render helper,renderWithProviders(ui, options), and use it everywhere: each test states only what is special ("signed in as Asha"). - 5
A reducer (Topic 9.4) is a pure function:
(state, action) => newState, with no React inside. So test it like any function: call it with a state and an action and check the result. No render, no act, microseconds per test. Test every action type, the edge cases (toggling an id that doesn't exist, an unknown action) and that it doesn't mutate the old state. Redux Toolkit slices are the same:slice.reducer(state, action)is a plain function you can call directly. - 6
For state libraries, use a real store, created fresh for each test: a
makeStore(preloadedState)function for Redux Toolkit, a newQueryClientfor TanStack Query, a reset for Zustand stores. Shared module-level stores carry state from one test into the next, which causes tests that pass alone and fail together. MockinguseSelectoror the store instead tests nothing real; the Redux docs recommend real stores in integration tests.
Explain it without notes
When do you test a custom hook with renderHook and when through a component?
Why must you read result.current after each update, and why wrap hook calls in act?
How do you test components that need context, a router or a store?
Why are reducers easy to test, and what should reducer tests cover?
Practice
Write a renderHook test (live helper style) for useToggle(initial) returning [on, toggle]: it starts at the initial value and flips on toggle.
Write a custom renderWithTheme(ui, { theme }) helper for a ThemeContext, and a test that a ThemedButton shows class dark in dark mode.
Trade-offs
- ↔
renderHooktests are precise and fast but test the hook's API, so they break if you reshape what it returns; component tests are sturdier but less direct. - ↔
A big shared
renderWithProviderskeeps tests short, but can hide what a test depends on; keep its options explicit and its defaults minimal.
Done when you can
I can test a custom hook with
renderHook,act,result.currentandrerender.I can supply context providers with a
wrapper.I can write a custom render helper with fresh stores per test.
I can test reducers and Redux slices as plain functions.
I know when to test a hook directly and when through a component.