How to Test React Hooks

Test React hooks directly with renderHook, act, rerender, and waitFor instead of wrapping every hook in a component.

7 min read

You can test React hooks directly with the renderHook helper from React Testing Library. This guide shows how to test React hooks in isolation, covering state updates, prop changes, async work, and context providers.

For hooks that only matter inside a component, testing through the component is usually clearer. A hook is pure logic, so testing it directly removes the noise of surrounding markup.

Render a hook with renderHook

A counter hook is a small, testable unit of stateful logic.

App.jsxApp.jsx
// src/useCounter.js
import { useState } from "react";
 
export function useCounter(step = 1) {
  const [count, setCount] = useState(0);
 
  function increment() {
    setCount((value) => value + step);
  }
 
  return { count, increment };
}

The renderHook helper mounts the hook in a tiny test component and exposes its return value through result.current. The hook runs on mount and its latest value is read the same way a component would read it.

App.jsxApp.jsx
import { renderHook } from "@testing-library/react";
import { useCounter } from "./useCounter";
 
test("starts at zero", () => {
  const { result } = renderHook(() => useCounter());
 
  expect(result.current.count).toBe(0);
});

The hook runs once on mount, and result.current holds the latest committed return value. Reading it is like reading the output a component would render, except the component is generated by the helper.

Update state inside act

Calling a state update outside React's act wrapper leaves the update unflushed, so result.current would still show the old value. Wrap the update in act so the test reads the value after re-render.

App.jsxApp.jsx
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "./useCounter";
 
test("increments the count", () => {
  const { result } = renderHook(() => useCounter());
 
  act(() => {
    result.current.increment();
  });
 
  expect(result.current.count).toBe(1);
});

The increment function uses a functional update, so the next value is based on the previous value. After act, the hook has re-rendered and result.current reflects the new count, just as it would in a real component.

Change inputs with rerender

Hooks receive props through the render callback. Pass initialProps on the first render and rerender with new values, the same way a component receives new props over time.

App.jsxApp.jsx
test("uses the latest step after rerender", () => {
  const { result, rerender } = renderHook(
    ({ step }) => useCounter(step),
    { initialProps: { step: 5 } }
  );
 
  act(() => result.current.increment());
  expect(result.current.count).toBe(5);
 
  rerender({ step: 10 });
  act(() => result.current.increment());
  expect(result.current.count).toBe(15);
});

The first increment adds the initial step of five. After rerender, the same increment function adds the new step of ten, so the count moves from five to fifteen. The hook keeps its state while its inputs change, which is the same re-render semantics a component experiences when its parent passes new props.

Wait for async results

A hook that fetches data needs waitFor, the same async utility used for components. The fetch is stubbed so the test controls when and what the response resolves to.

App.jsxApp.jsx
import { renderHook, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import { useFetchName } from "./useFetchName";
 
test("returns the name after loading", async () => {
  vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
    json: async () => ({ name: "Ada" }),
  }));
 
  const { result } = renderHook(() => useFetchName());
 
  await waitFor(() => expect(result.current).toBe("Ada"));
});

waitFor keeps retrying until the assertion passes. The hook starts with a loading value, then updates after the fetch resolves, and the assertion moves from the loading state to the final name. For more on that pattern, see how to test async React components.

Provide context with a wrapper

Hooks that call useContext need a provider above them, and renderHook would otherwise render them without one. Pass a wrapper component that renders the providers the hook expects.

App.jsxApp.jsx
import { renderHook } from "@testing-library/react";
import { AuthProvider } from "./AuthContext";
import { useAuth } from "./useAuth";
 
test("reads the signed in user", () => {
  const wrapper = ({ children }) => (
    <AuthProvider user="Ada">{children}</AuthProvider>
  );
 
  const { result } = renderHook(() => useAuth(), { wrapper });
 
  expect(result.current.user).toBe("Ada");
});

The wrapper surrounds the hook the same way a provider surrounds a component tree, so the hook reads the provider value instead of a fallback. The helpers themselves come from React Testing Library. Routers, themes, and query clients all use the same wrapper pattern when a hook depends on them.

Common mistakes

The most common failures come from reading stale values or forgetting the providers a hook needs.

  • Testing a hook that is only used by one component through renderHook instead of the component itself.
  • Reading state after an update without wrapping it in act.
  • Forgetting a wrapper, so a context hook reads its fallback instead of the provider value.
  • Treating result.current as a live binding. Re-read it after every update.

What to learn next

For hooks that fetch, combine this with how to mock API requests with MSW. Hooks that belong to routed pages can be tested through the page itself.

Rune AI

Rune AI

Key Insights

  • Use renderHook for standalone hooks and render for component behavior.
  • Read the latest return value from result.current.
  • Wrap state updates in act so React flushes the re-render.
  • Pass initialProps and rerender to change hook inputs.
  • Use a wrapper to provide context and providers.
RunePowered by Rune AI

Frequently Asked Questions

Should I always test hooks with renderHook?

No. When a hook is tied to a component, testing through the component with render is usually clearer. Use renderHook for standalone reusable logic or library hooks.

Why do updates need act?

act wraps a state update so React flushes its effects and re-render before the test reads result.current. Testing Library helpers already use it internally.

Conclusion

renderHook runs a hook inside a minimal test component. Use act for updates, rerender for new props, and waitFor for async results.