frontend / testing / 07_testing_hooks.md

Testing Custom Hooks (renderHook, act)

5 min read source

Testing Custom Hooks (renderHook, act)

TL;DR

Custom hooks aren’t standalone functions — they require a React render context to use useState/useEffect/etc. renderHook from @testing-library/react mounts a tiny test component and exposes the hook’s return value. act wraps state updates so React batches them and effects run. Test the hook’s behavior (return values, callbacks) — not its internal state. Most custom hooks are integration-test material, not unit; pure utility functions extracted from a hook are easier to test as plain functions.

Interview Q&A

Q: Why can’t you just call a hook like a function?

A: Hooks call useState etc., which require an active React render context — dispatcher is set during rendering, throws “Invalid hook call” otherwise.

// Throws
const [count, setCount] = useState(0);

renderHook solves this by mounting a tiny throwaway component that calls the hook and exposes the result.

Q: Basic renderHook example.

A:

import { renderHook, act } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { useCounter } from "./useCounter";

describe("useCounter", () => {
  it("starts at 0 by default", () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it("increments", () => {
    const { result } = renderHook(() => useCounter());
    act(() => result.current.increment());
    expect(result.current.count).toBe(1);
  });

  it("accepts an initial value", () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });
});

Key things:

  • result.current is the latest return value of the hook (re-read every time).
  • act(() => fn()) wraps the state-mutating call so React batches and re-renders.
  • Without act, you’ll see warnings and stale result.current.

Q: Why result.current and not result?

A: The hook re-runs on every render with potentially different return values. result.current always points to the latest render’s return. Storing const { increment } = result.current once captures the first render’s value and quickly goes stale.

// Wrong — captures first render
const { result } = renderHook(() => useCounter());
const { increment } = result.current;     // first-render reference
act(() => increment());
// works for this case but breaks if the hook returns different functions per render

// Right — read current each time
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);

Q: Re-rendering with new props.

A: rerender:

it("syncs with prop changes", () => {
  const { result, rerender } = renderHook(({ initial }) => useCounter(initial), {
    initialProps: { initial: 0 },
  });

  expect(result.current.count).toBe(0);

  rerender({ initial: 10 });
  expect(result.current.count).toBe(10);     // if the hook reacts to prop change
});

Tests how the hook responds when its inputs change between renders.

Q: Testing async hooks (useFetch, etc.).

A: With MSW + waitFor:

it("fetches and returns data", async () => {
  server.use(http.get("/api/user/1", () => HttpResponse.json({ id: 1, name: "Ada" })));

  const { result } = renderHook(() => useFetch("/api/user/1"));

  expect(result.current.isLoading).toBe(true);

  await waitFor(() => {
    expect(result.current.isLoading).toBe(false);
    expect(result.current.data).toEqual({ id: 1, name: "Ada" });
  });
});

waitFor polls until the assertion passes. Don’t use raw await new Promise(setTimeout) — flaky.

Q: Testing a hook that uses Context (theme, auth, etc.).

A: Provide a wrapper:

const wrapper = ({ children }: { children: React.ReactNode }) => (
  <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
);

const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current).toBe("dark");

A reusable AllProviders wrapper is common — bundles Theme, Router, Query, etc., for any hook test.

Q: Testing a hook that subscribes to an external store (Zustand, Redux).

A: Reset the store between tests; use the wrapper if the store is provided via Context:

import { useUserStore } from "@/stores/user";

beforeEach(() => {
  useUserStore.setState({ user: null });    // reset
});

it("returns user state", () => {
  useUserStore.setState({ user: { id: 1, name: "Ada" } });
  const { result } = renderHook(() => useUser());
  expect(result.current?.name).toBe("Ada");
});

For Pinia (Vue), setActivePinia(createPinia()) in beforeEach. For Redux, wrap in <Provider store={store}>.

Q: What’s act and when do you need it explicitly?

A: act wraps state updates so React batches them and processes effects synchronously inside its callback. Most modern test libraries (renderHook, user-event) wrap automatically. Explicit act for:

  • Direct calls to hook returns that mutate state.
  • Timer-based updates (act(() => vi.advanceTimersByTime(100))).
  • Effect-triggered state changes (await act(async () => {}) flushes pending effects).
// Sync state update
act(() => result.current.increment());

// Async — for effects with promises
await act(async () => {
  result.current.fetchData();
});

If you see “not wrapped in act(…)” warnings, find the unwrapped state update and either wrap it or use a higher-level helper.

Q: When not to use renderHook?

A:

  • Pure utility functions — extract them out of the hook and unit-test directly (formatDate, parseQuery). Faster, simpler.
  • Hooks tightly coupled to a single component — test the component, the hook gets exercised along the way. RTL integration test does both.
  • Hooks with significant DOM interaction — better as component tests that render real markup.

renderHook shines for: reusable hooks with non-trivial logic, shared across components (auth, data fetching, subscriptions).

Q: Testing cleanup behavior.

A: unmount():

it("cleans up subscription on unmount", () => {
  const subscribe = vi.fn(() => vi.fn());     // returns unsubscribe
  const { unmount } = renderHook(() => useSubscription({ subscribe }));

  expect(subscribe).toHaveBeenCalledTimes(1);
  const unsub = subscribe.mock.results[0].value;

  unmount();
  expect(unsub).toHaveBeenCalledOnce();
});

Critical for hooks that attach listeners — the test catches forgotten cleanup.

Gotchas / edge cases

  • Hook tests don’t replace component tests — the hook may work in isolation but break in a component because of context, timing, or interactions.
  • result.current.fn() outside act in a sync test usually works but warns; wrap to be safe.
  • Async tests need await act(async () => {}) when manually flushing — otherwise effects don’t run.
  • renderHook mounts a real component — anything that runs on mount runs in tests. Side effects fire.
  • Multiple hooks in one renderHook — call them inside the callback:
    const { result } = renderHook(() => {
      const counter = useCounter();
      const theme = useTheme();
      return { counter, theme };
    });
  • StrictMode in testsrenderHook doesn’t enable StrictMode by default; pass legacyRoot: false and wrap with <StrictMode> to catch double-invoke bugs in dev.

What a senior is expected to say

  • renderHook mounts a tiny component to test a hook in isolation. Read result.current each time — it’s the latest return value.”
  • “Wrap state-mutating calls in act. Async work needs await act(async () => {}) or higher-level waitFor.”
  • “Extract pure logic out of hooks for direct unit tests; reach for renderHook when the hook genuinely needs React’s render context.”
  • “For Context-consuming hooks, pass a wrapper. For store-consuming hooks, reset the store between tests.”
  • “Test cleanup with unmount() — a forgotten unsubscribe is a memory leak that won’t show up in normal renders.”

Cross-references

Further reading