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.
// 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.
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.
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.
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.
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.
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
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.
Frequently Asked Questions
Should I always test hooks with renderHook?
Why do updates need act?
Conclusion
renderHook runs a hook inside a minimal test component. Use act for updates, rerender for new props, and waitFor for async results.
More in this topic
How to Build a Dropdown Menu in React
Build a React dropdown menu with the ARIA menu button pattern. Handle open and close, keyboard arrows, and clicks outside the menu.
How to Animate React Components with Motion
Animate React components with the Motion library. Set up motion, add enter, hover, and exit animations, and respect reduced motion.
Headless UI Components Explained: Logic Without Locked Styling
Understand headless UI components and how libraries like Radix give you unstyled, accessible behavior that you style yourself.