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.currentis 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 staleresult.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()outsideactin 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. renderHookmounts 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 tests —
renderHookdoesn’t enable StrictMode by default; passlegacyRoot: falseand wrap with<StrictMode>to catch double-invoke bugs in dev.
What a senior is expected to say
- “
renderHookmounts a tiny component to test a hook in isolation. Readresult.currenteach time — it’s the latest return value.” - “Wrap state-mutating calls in
act. Async work needsawait act(async () => {})or higher-levelwaitFor.” - “Extract pure logic out of hooks for direct unit tests; reach for
renderHookwhen 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
- React Testing Library general: 03_react_testing_library.md
- Async testing patterns: 06_async_ui_testing.md
- MSW for hook fetch tests: 05_msw_network_mocking.md
useEffectdeep dive (what you’re testing): ../05_react/use_effect_deep.md
Further reading
- Testing Library —
renderHook: https://testing-library.com/docs/react-testing-library/api/#renderhook - React docs —
act: https://react.dev/reference/react/act - Kent C. Dodds — “How to test custom React hooks”: https://kentcdodds.com/blog/how-to-test-custom-react-hooks