Command Palette

Search for a command to run...

Hectal
PHASE 17Advanced ~31 min· topic 6 of 7

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 render that 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.

Decide: test the hook directly or through a component?diagram
Rendering diagram…

02Run the hook and read result.current

Call the hook's functions inside act, then read result.current again for the new values.

useCounter.test.jswhole filejsx
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.

useCounter.test.jswhole filejsx
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.

test/utils.jsxwhole filejsx
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 }) };
}
Supply providers with a wrapper and a custom renderdiagram
Rendering diagram…

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.

todosReducer.test.jswhole filejavascript
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. 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 the act(() => ...) wrapper around increment(): what happens in the preview? (In a Vitest setup with IS_REACT_ACT_ENVIRONMENT on, you'd also see an act warning.)

  2. 2

    Test a min boundary

    Add a test that useCounter({ initial: 0, min: 0 }) stays at 0 after decrement(). Predict, then run.

  3. 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

renderHook: result.current, act and rerender New tab

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

Press Run to see the page.

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 0
Context: a wrapper, a custom render and a hook that needs a provider New tab

Each 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

Press Run to see the page.

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 menu
Reducers as plain functions, and a real Redux store per test New tab

Reducer 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

Press Run to see the page.

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
A custom render for TanStack Query: a fresh client, no retriesjsx
// 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 />).

terminal
$ Console
── what you'll see ──
✗ signed out: shows a log in link: useAuth must be used inside <AuthProvider>

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.

terminal
$ Console
── what you'll see ──
✗ component + real store: clicking adds ten: expected "Points: 10", got "Points: 20"

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's result.current is 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.current always reflects the last committed render.

  • ▸

    RTL's render and renderHook accept wrapper plus container, baseElement, hydrate and, in RTL 16, reactStrictMode (render inside <StrictMode>), which you can also set globally with configure({ 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 a renderWithProviders helper that returns the store too, so a test can assert on dispatched results or preload state without mocking.

Remember this

  1. 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/react renders a hidden test component that calls your hook and returns { result, rerender, unmount }.

  2. 2

    result.current is the hook's return value from the latest render. Read it after every change, never keep a copy: const { count } = result.current is 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's act), so React applies the update before your next line reads result.current. user-event and render do this for you; direct calls on a hook's functions don't.

  3. 3

    renderHook(callback, { initialProps }) passes initialProps to the callback, and rerender(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 with await waitFor(() => expect(result.current.status).toBe("done")).

  4. 4

    Components and hooks that read context (Phase 9) need a provider above them, or useContext returns the default value. render(ui, { wrapper: Wrapper }) and renderHook(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. 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. 6

    For state libraries, use a real store, created fresh for each test: a makeStore(preloadedState) function for Redux Toolkit, a new QueryClient for 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. Mocking useSelector or the store instead tests nothing real; the Redux docs recommend real stores in integration tests.

Explain it without notes

01

When do you test a custom hook with renderHook and when through a component?

02

Why must you read result.current after each update, and why wrap hook calls in act?

03

How do you test components that need context, a router or a store?

04

Why are reducers easy to test, and what should reducer tests cover?

Practice

01

Write a renderHook test (live helper style) for useToggle(initial) returning [on, toggle]: it starts at the initial value and flips on toggle.

02

Write a custom renderWithTheme(ui, { theme }) helper for a ThemeContext, and a test that a ThemedButton shows class dark in dark mode.

Trade-offs

  • ↔

    renderHook tests 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 renderWithProviders keeps 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.current and rerender.

  • 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.