Frontend / Testing / 07_testing_hooks.md

Testing Custom Hooks (renderHook, act)

Updated 5 interview angles 6 min read source
On this page14
  1. Why can’t you just call a hook like a function?
  2. Basic renderHook example.
  3. Why result.current and not result?
  4. Re-rendering with new props.
  5. Testing async hooks (useFetch, etc.).
  6. Testing a hook that uses Context (theme, auth, etc.).
  7. Testing a hook that subscribes to an external store (Zustand, Redux).
  8. What’s act and when do you need it explicitly?
  9. When not to use renderHook?
  10. Testing cleanup behavior.
  11. Gotchas
  12. Related
  13. Further reading
  14. Interview angle

Testing Custom Hooks (renderHook, act)

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.

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

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

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

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

Basic renderHook example.

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

Why result.current and not result?

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.

ts
// Wrong — captures first render
const { result } = renderHook(() => useCounter());
// first-render reference
const { increment } = result.current;
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);

Re-rendering with new props.

rerender:

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

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

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

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

Testing async hooks (useFetch, etc.).

With MSW + waitFor:

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

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

Provide a wrapper:

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

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

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

ts
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}>.

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

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).
ts
// 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.

When not to use renderHook?

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

Testing cleanup behavior.

unmount():

ts
it("cleans up subscription on unmount", () => {
  // returns unsubscribe
  const subscribe = vi.fn(() => vi.fn());
  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

  • 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:
    ts
    const { result } = renderHook(() => {
      const counter = useCounter();
      const theme = useTheme();
      return { counter, theme };
    });
  • StrictMode in tests — renderHook doesn’t enable StrictMode by default; pass legacyRoot: false and wrap with <StrictMode> to catch double-invoke bugs in dev.

Further reading

Interview angle 5

  • “How do you test a hook?” - renderHook, which mounts a minimal component so the hook has a render context. Read result.current fresh on each assertion, because it is the latest return value and destructuring it once captures a stale snapshot.
  • “When do you need act?” - around anything that updates state, so React can flush the resulting render before you assert. Async work needs await act(async () => {}), or better, a higher-level waitFor that expresses what you are waiting for.
  • “Should everything be tested through renderHook?” - no. Pull pure logic out of the hook and unit test it directly, which is faster and clearer. Reserve renderHook for behaviour that genuinely needs React - state, effects, context, subscriptions.
  • “How do you test a hook that reads context or a store?” - pass a wrapper supplying the provider. For a store, reset it between tests, or state leaks from one test into the next and you get order-dependent failures.
  • “How do you test cleanup?” - call unmount() and assert the teardown happened: the listener removed, the subscription closed, the timer cleared. A missing cleanup never shows up in a normal render, so this is the only place it is catchable.