React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

hooksrenderHookactcustom-hookstesting

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:

  • renderHook for testing hooks outside of components
  • rerender with new props to trigger hook re-execution
  • Fake timers for debounce testing
  • Mocking fetch for async hook testing
  • waitFor to wait for async state updates

Deep Dive

How It Works

  • renderHook creates a minimal wrapper component that calls your hook and exposes result.current -- the current return value
  • result.current is a ref-like object -- it always reflects the latest return value after re-renders
  • act() 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 wrapper option 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 -- Calling result.current.increment() without act() triggers a warning and may not update result.current. Fix: Wrap synchronous state-triggering calls in act().

  • Reading stale result.current -- Destructuring const { count } = result.current captures a snapshot. Fix: Always read from result.current directly after state changes.

  • Testing hooks that only work in components -- Hooks that use useContext throw without a provider. Fix: Pass a wrapper option to renderHook.

  • Async hooks and missing waitFor -- If a hook triggers an async effect, the test may complete before the state updates. Fix: Use waitFor or findBy to wait for the expected state.

  • Fake timers and async code -- vi.useFakeTimers() can interfere with waitFor and Promises. Fix: If using fake timers with async hooks, call vi.advanceTimersByTime() inside act() and ensure Promises resolve.

Alternatives

AlternativeUse WhenDon't Use When
Test through a componentThe hook is simple and tightly coupled to a specific componentThe hook is reused across many components
Storybook play functionsYou want to test hooks via component interactions in storiesYou need isolated unit tests with mocks
Integration testThe hook interacts with external APIs you want to test togetherYou want fast, isolated tests

FAQs

What is renderHook and when should I use it?
  • renderHook creates 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 changes

Always 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 unmount
How 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 renderHook for 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.