Testing Custom Hooks
Test custom hooks in isolation using renderHook -- verify state changes, async behavior, and context dependencies.
Recipe
Quick-reference recipe card -- copy-paste ready.
import { renderHook, act, waitFor } from "@testing-library/react";
// Test a synchronous hook
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
// Test an async hook
const { result } = renderHook(() => useFetch("/api/users"));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.data).toEqual([{ id: 1, name: "Alice" }]);
});
// Test a hook with context
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
const { result } = renderHook(() => useAuth(), { wrapper });When to reach for this: When you have a custom hook with logic worth testing independently of any particular component.
Working Example
// src/hooks/use-debounce.ts
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}// src/hooks/use-debounce.test.ts
import { renderHook, act } from "@testing-library/react";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import { useDebounce } from "./use-debounce";
describe("useDebounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("returns the initial value immediately", () => {
const { result } = renderHook(() => useDebounce("hello", 500));
expect(result.current).toBe("hello");
});
it("debounces value changes", () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "hello", delay: 500 } }
);
// Update the value
rerender({ value: "world", delay: 500 });
expect(result.current).toBe("hello"); // not yet updated
// Fast-forward time
act(() => {
vi.advanceTimersByTime(500);
});
expect(result.current).toBe("world"); // now updated
});
it("resets timer on rapid value changes", () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "a", delay: 300 } }
);
rerender({ value: "ab", delay: 300 });
act(() => vi.advanceTimersByTime(200));
rerender({ value: "abc", delay: 300 });
act(() => vi.advanceTimersByTime(200));
// Only 200ms since last change -- still debouncing
expect(result.current).toBe("a");
act(() => vi.advanceTimersByTime(100));
expect(result.current).toBe("abc");
});
});// src/hooks/use-fetch.ts
import { useState, useEffect } from "react";
interface UseFetchResult<T> {
data: T | null;
error: string | null;
loading: boolean;
}
export function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function fetchData() {
setLoading(true);
setError(null);
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!cancelled) setData(json);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : "Unknown error");
} finally {
if (!cancelled) setLoading(false);
}
}
fetchData();
return () => { cancelled = true; };
}, [url]);
return { data, error, loading };
}// src/hooks/use-fetch.test.ts
import { renderHook, waitFor } from "@testing-library/react";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import { useFetch } from "./use-fetch";
describe("useFetch", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
it("starts in loading state", () => {
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify([]), { status: 200 })
);
const { result } = renderHook(() => useFetch("/api/users"));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
expect(result.current.error).toBeNull();
});
it("returns data on success", async () => {
const users = [{ id: 1, name: "Alice" }];
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify(users), { status: 200 })
);
const { result } = renderHook(() => useFetch("/api/users"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(users);
expect(result.current.error).toBeNull();
});
it("returns error on failure", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(null, { status: 500 })
);
const { result } = renderHook(() => useFetch("/api/users"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe("HTTP 500");
expect(result.current.data).toBeNull();
});
it("refetches when URL changes", async () => {
const mockFetch = vi.mocked(fetch);
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ id: 1 }), { status: 200 })
);
const { result, rerender } = renderHook(
({ url }) => useFetch(url),
{ initialProps: { url: "/api/users/1" } }
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(mockFetch).toHaveBeenCalledWith("/api/users/1");
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ id: 2 }), { status: 200 })
);
rerender({ url: "/api/users/2" });
await waitFor(() => expect(result.current.data).toEqual({ id: 2 }));
expect(mockFetch).toHaveBeenCalledWith("/api/users/2");
});
});What this demonstrates:
renderHookfor testing hooks outside of componentsrerenderwith new props to trigger hook re-execution- Fake timers for debounce testing
- Mocking
fetchfor async hook testing waitForto wait for async state updates
Deep Dive
How It Works
renderHookcreates a minimal wrapper component that calls your hook and exposesresult.current-- the current return valueresult.currentis a ref-like object -- it always reflects the latest return value after re-rendersact()is required when triggering state updates from outside React's render cycle (e.g., calling methods returned by hooks)rerender()re-renders the wrapper component with new props, which re-runs the hook with updated arguments- The
wrapperoption lets you wrap the hook in providers like context, routers, or query clients
Variations
Testing a hook with context:
// src/hooks/use-theme.test.tsx
import { renderHook, act } from "@testing-library/react";
import { ThemeProvider, useTheme } from "./theme-context";
it("toggles theme", () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.theme).toBe("light");
act(() => {
result.current.toggleTheme();
});
expect(result.current.theme).toBe("dark");
});Testing cleanup on unmount:
it("cancels pending requests on unmount", async () => {
vi.mocked(fetch).mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 5000))
);
const { unmount } = renderHook(() => useFetch("/api/slow"));
unmount();
// No state update errors should occur after unmount
// The cancelled flag in the hook prevents setState after unmount
});TypeScript Notes
// Typing renderHook with initialProps
const { result, rerender } = renderHook(
({ url }: { url: string }) => useFetch<User[]>(url),
{ initialProps: { url: "/api/users" } }
);
// rerender expects the same props type: { url: string }
// result.current is typed as the hook's return type
const data: User[] | null = result.current.data;Gotchas
-
Forgetting
act()for synchronous state updates -- Callingresult.current.increment()withoutact()triggers a warning and may not updateresult.current. Fix: Wrap synchronous state-triggering calls inact(). -
Reading stale
result.current-- Destructuringconst { count } = result.currentcaptures a snapshot. Fix: Always read fromresult.currentdirectly after state changes. -
Testing hooks that only work in components -- Hooks that use
useContextthrow without a provider. Fix: Pass awrapperoption torenderHook. -
Async hooks and missing
waitFor-- If a hook triggers an async effect, the test may complete before the state updates. Fix: UsewaitFororfindByto wait for the expected state. -
Fake timers and async code --
vi.useFakeTimers()can interfere withwaitForand Promises. Fix: If using fake timers with async hooks, callvi.advanceTimersByTime()insideact()and ensure Promises resolve.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Test through a component | The hook is simple and tightly coupled to a specific component | The hook is reused across many components |
| Storybook play functions | You want to test hooks via component interactions in stories | You need isolated unit tests with mocks |
| Integration test | The hook interacts with external APIs you want to test together | You want fast, isolated tests |
FAQs
What is renderHook and when should I use it?
renderHookcreates a minimal wrapper component to test hooks in isolation.- Use it when a hook has logic worth testing independently of any particular component.
- For simple hooks tightly coupled to one component, test through the component instead.
Why do I need act() when testing synchronous state updates in hooks?
Calling a state-updating function like result.current.increment() outside of React's render cycle requires act() to flush updates. Without it, result.current may not reflect the latest state.
Gotcha: What happens if I destructure result.current?
Destructuring captures a snapshot at that moment:
const { count } = result.current; // stale after state changesAlways read from result.current directly after state changes to get the latest value.
How do I test a hook that requires a context provider?
Pass a wrapper option to renderHook:
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
const { result } = renderHook(() => useAuth(), { wrapper });How do I test a hook with changing arguments?
Use rerender with new props:
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: "hello" } }
);
rerender({ value: "world" });Gotcha: Why does vi.useFakeTimers() cause waitFor to hang?
Fake timers freeze the polling interval used by waitFor. Either advance timers inside act() before using waitFor, or use vi.useFakeTimers({ shouldAdvanceTime: true }).
How do I mock fetch for testing an async hook?
vi.stubGlobal("fetch", vi.fn());
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify(data), { status: 200 })
);How do I test cleanup behavior when a hook unmounts?
Use the unmount function from renderHook:
const { unmount } = renderHook(() => useFetch("/api/slow"));
unmount();
// Verify no state update errors occur after unmountHow do I type renderHook with initialProps in TypeScript?
const { result, rerender } = renderHook(
({ url }: { url: string }) => useFetch<User[]>(url),
{ initialProps: { url: "/api/users" } }
);
// rerender expects the same props type: { url: string }When should I test a hook in isolation vs through a component?
- Test in isolation when the hook is reused across many components.
- Test through a component when the hook is simple and tightly coupled.
- Use
renderHookfor hooks with complex logic like debouncing, data fetching, or state machines.
How do I use fake timers to test a debounce hook?
vi.useFakeTimers();
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: "a" } }
);
rerender({ value: "b" });
act(() => vi.advanceTimersByTime(500));
expect(result.current).toBe("b");
vi.useRealTimers();What does result.current return for a hook like useFetch?
It returns the hook's return type. For useFetch<T>, that would be { data: T | null; error: string | null; loading: boolean }. TypeScript infers this automatically.
Related
- React Testing Library Fundamentals -- render and query basics
- Testing Async Behavior -- waitFor, timers, and suspense
- Testing Context & State Management -- testing hooks with context and stores
- Mocking in Tests -- mocking fetch and other dependencies