
Testing Custom Hooks the Right Way
Custom hooks are where a lot of React logic ends up: debouncing, fetching, syncing with localStorage, subscribing to browser APIs, managing complex form state. That makes them worth testing. But hooks can only run inside a component, so you can't simply call useDebounce("a", 300) in a test and check the return value. React throws an "Invalid hook call" error.
There are two good ways around this: render the hook through a tiny test harness with renderHook, or test it indirectly through a real component that uses it. Each fits different situations, and choosing well is half of "testing hooks the right way". The other half is handling the things hooks usually deal with: state updates, timers, async work, context, and cleanup.
This guide uses Vitest and React Testing Library with React 19. It covers renderHook from basics to rerendering with new arguments, updating state with act, fake timers, async hooks with MSW, hooks that need providers, and verifying cleanup on unmount.
Setup
You need Vitest, jsdom, and React Testing Library:
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom
renderHook ships with @testing-library/react itself. The older @testing-library/react-hooks package is deprecated and doesn't support React 18 or later, so don't install it. If you haven't configured Vitest for React yet, testing React components with Vitest and Testing Library walks through the config and setup file.
renderHook Basics
renderHook renders a hidden test component that calls your hook, and gives you the latest return value in result.current.
Start with a simple counter hook:
// src/hooks/useCounter.ts
import { useCallback, useState } from "react";
export function useCounter(initial = 0, { min = -Infinity, max = Infinity } = {}) {
const [count, setCount] = useState(initial);
const increment = useCallback(() => setCount((c) => Math.min(c + 1, max)), [max]);
const decrement = useCallback(() => setCount((c) => Math.max(c - 1, min)), [min]);
const reset = useCallback(() => setCount(initial), [initial]);
return { count, increment, decrement, reset };
}
// src/hooks/useCounter.test.ts
import { act, renderHook } from "@testing-library/react";
import { describe, expect, test } from "vitest";
import { useCounter } from "./useCounter";
describe("useCounter", () => {
test("starts at the initial value", () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
});
test("increments and decrements", () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
act(() => result.current.increment());
expect(result.current.count).toBe(2);
act(() => result.current.decrement());
expect(result.current.count).toBe(1);
});
test("respects max", () => {
const { result } = renderHook(() => useCounter(9, { max: 10 }));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(10);
});
});
Two important details:
- Wrap state updates in
act. Callingincrementschedules a state update.actmakes React process it and re-render before your assertion runs. Without it,result.current.countwould still hold the old value, and React would log a warning. - Always read
result.currentafter the update.result.currentis replaced on every render. If you destructure early, as inconst { count } = result.current, you capture a stale snapshot.
// Wrong: count is captured before the update
const { count, increment } = result.current;
act(() => increment());
expect(count).toBe(1); // fails, still 0
// Right: read after the update
act(() => result.current.increment());
expect(result.current.count).toBe(1);
Rerendering With New Arguments
Many hooks respond to changing inputs. renderHook accepts initialProps, and the returned rerender function passes new ones:
// src/hooks/usePrevious.ts
import { useEffect, useRef } from "react";
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
// src/hooks/usePrevious.test.ts
import { renderHook } from "@testing-library/react";
import { expect, test } from "vitest";
import { usePrevious } from "./usePrevious";
test("returns the value from the previous render", () => {
const { result, rerender } = renderHook(({ value }) => usePrevious(value), {
initialProps: { value: "a" },
});
expect(result.current).toBeUndefined();
rerender({ value: "b" });
expect(result.current).toBe("a");
rerender({ value: "c" });
expect(result.current).toBe("b");
});
The callback receives the props object, so TypeScript infers the type of value from initialProps. This pattern is how you test any hook whose behavior depends on its arguments changing over time.
Testing Hooks With Timers
Debounce and throttle hooks are classic candidates for hook tests. Here's a debounced value hook:
// src/hooks/useDebouncedValue.ts
import { useEffect, useState } from "react";
export function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
Use Vitest's fake timers to control time precisely, and wrap timer advances in act, because they trigger state updates:
// src/hooks/useDebouncedValue.test.ts
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { useDebouncedValue } from "./useDebouncedValue";
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("updates only after the delay", () => {
const { result, rerender } = renderHook(
({ value }) => useDebouncedValue(value, 500),
{ initialProps: { value: "r" } },
);
rerender({ value: "re" });
rerender({ value: "rea" });
expect(result.current).toBe("r");
act(() => {
vi.advanceTimersByTime(499);
});
expect(result.current).toBe("r");
act(() => {
vi.advanceTimersByTime(1);
});
expect(result.current).toBe("rea");
});
test("restarts the timer on every change", () => {
const { result, rerender } = renderHook(
({ value }) => useDebouncedValue(value, 500),
{ initialProps: { value: "a" } },
);
rerender({ value: "b" });
act(() => {
vi.advanceTimersByTime(400);
});
rerender({ value: "c" });
act(() => {
vi.advanceTimersByTime(400);
});
expect(result.current).toBe("a");
act(() => {
vi.advanceTimersByTime(100);
});
expect(result.current).toBe("c");
});
The second test proves the important property of a debounce: intermediate values never appear. Testing the boundary, 499ms versus 500ms, catches off-by-one mistakes. For the theory behind these hooks, see debouncing and throttling user input in React.
Testing Async Hooks
Hooks that fetch data update state after a promise resolves. Use waitFor to retry an assertion until it passes.
// src/hooks/useUser.ts
import { useEffect, useState } from "react";
type User = { id: string; name: string };
type State =
| { status: "loading" }
| { status: "success"; user: User }
| { status: "error"; error: Error };
export function useUser(id: string) {
const [state, setState] = useState<State>({ status: "loading" });
useEffect(() => {
const controller = new AbortController();
setState({ status: "loading" });
fetch(`https://api.example.com/users/${id}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<User>;
})
.then((user) => setState({ status: "success", user }))
.catch((error: Error) => {
if (error.name !== "AbortError") setState({ status: "error", error });
});
return () => controller.abort();
}, [id]);
return state;
}
Rather than mocking fetch, intercept the request with MSW so the hook runs its real code. This assumes the MSW server is started in your test setup, as described in mocking API calls in React tests with MSW.
// src/hooks/useUser.test.ts
import { renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { expect, test } from "vitest";
import { server } from "../mocks/node";
import { useUser } from "./useUser";
const url = "https://api.example.com/users/:id";
test("loads a user", async () => {
server.use(
http.get(url, ({ params }) => HttpResponse.json({ id: params.id, name: "Ada" })),
);
const { result } = renderHook(() => useUser("1"));
expect(result.current.status).toBe("loading");
await waitFor(() => expect(result.current.status).toBe("success"));
expect(result.current).toEqual({ status: "success", user: { id: "1", name: "Ada" } });
});
test("reports HTTP errors", async () => {
server.use(http.get(url, () => new HttpResponse(null, { status: 404 })));
const { result } = renderHook(() => useUser("missing"));
await waitFor(() => expect(result.current.status).toBe("error"));
if (result.current.status === "error") {
expect(result.current.error.message).toBe("HTTP 404");
}
});
test("refetches when the id changes", async () => {
server.use(
http.get(url, ({ params }) => HttpResponse.json({ id: params.id, name: `User ${params.id}` })),
);
const { result, rerender } = renderHook(({ id }) => useUser(id), {
initialProps: { id: "1" },
});
await waitFor(() => expect(result.current.status).toBe("success"));
rerender({ id: "2" });
await waitFor(() =>
expect(result.current).toEqual({ status: "success", user: { id: "2", name: "User 2" } }),
);
});
waitFor already wraps its checks in act, so you don't need to add it yourself. Keep a single assertion inside waitFor and put follow-up assertions after it, which gives clearer failure messages.
Hooks That Need Context
Hooks that call useContext, or library hooks like useQuery and useNavigate, need providers. Pass a wrapper component to renderHook:
// src/hooks/useCart.test.tsx
import type { ReactNode } from "react";
import { act, renderHook } from "@testing-library/react";
import { expect, test } from "vitest";
import { CartProvider } from "../context/CartContext";
import { useCart } from "./useCart";
function wrapper({ children }: { children: ReactNode }) {
return <CartProvider initialItems={[]}>{children}</CartProvider>;
}
test("adds items and computes the total", () => {
const { result } = renderHook(() => useCart(), { wrapper });
act(() => {
result.current.addItem({ id: "kb", name: "Keyboard", price: 80 });
result.current.addItem({ id: "ms", name: "Mouse", price: 25 });
});
expect(result.current.items).toHaveLength(2);
expect(result.current.total).toBe(105);
});
Note the .tsx extension, since the wrapper uses JSX. For TanStack Query hooks, create a fresh QueryClient inside a factory so tests don't share cached data:
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function createQueryWrapper() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
// usage: renderHook(() => useTodos(), { wrapper: createQueryWrapper() });
Testing That a Hook Throws Outside Its Provider
A common pattern is a hook that throws a helpful error when used outside its provider. renderHook rethrows render errors, so you can assert on them directly. React logs the error to the console as well, so silence it for this test:
import { renderHook } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { useCart } from "./useCart";
test("throws without a CartProvider", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => renderHook(() => useCart())).toThrow(
"useCart must be used within a CartProvider",
);
spy.mockRestore();
});
Testing Cleanup on Unmount
Hooks that add event listeners, subscriptions, or intervals must clean up. renderHook returns unmount, and spies let you verify cleanup:
// src/hooks/useWindowWidth.ts
import { useEffect, useState } from "react";
export function useWindowWidth() {
const [width, setWidth] = useState(() => window.innerWidth);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return width;
}
// src/hooks/useWindowWidth.test.ts
import { act, renderHook } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { useWindowWidth } from "./useWindowWidth";
test("tracks resize events and cleans up", () => {
const removeSpy = vi.spyOn(window, "removeEventListener");
const { result, unmount } = renderHook(() => useWindowWidth());
act(() => {
window.innerWidth = 500;
window.dispatchEvent(new Event("resize"));
});
expect(result.current).toBe(500);
unmount();
expect(removeSpy).toHaveBeenCalledWith("resize", expect.any(Function));
removeSpy.mockRestore();
});
jsdom lets you assign window.innerWidth directly. Dispatching a real resize event exercises the hook exactly as a browser would.
When to Test Through a Component Instead
renderHook is great for reusable, general-purpose hooks: utilities like useDebouncedValue, useLocalStorage, or usePrevious that many components share. Their public API is the hook itself, so testing it directly makes sense.
For hooks that exist mainly to organize one component's logic, like useCheckoutForm used only by CheckoutForm, test the component instead. A hook test there checks implementation details. If you later move logic between the hook and the component, the hook tests break even though users see no difference. Component tests that click buttons and read text are more stable and give more confidence.
A useful rule: test at the level where the behavior is a contract. If other developers import the hook, the hook is the contract. If only one component uses it, the component's UI is the contract. For more on how hooks are designed for reuse, see building your own custom hooks in React.
Common Mistakes When Testing Hooks
- Destructuring
result.currenttoo early. It captures one render's values. Always readresult.currentafter updates. - Forgetting
actaround state updates. Calls to state setters, timer advances, and dispatched events need it.waitForand user-event already handle it. - Using the deprecated
@testing-library/react-hookspackage. ImportrenderHookfrom@testing-library/react. - Mocking
fetchwith hand-written stubs. They drift from real behavior. Intercept requests with MSW so the hook's real code runs. - Sharing providers or caches between tests. Create a fresh wrapper per test, especially for query clients and stores.
- Leaving fake timers on. Restore real timers in
afterEach, or later tests that rely onwaitFormay hang. - Testing private component hooks in isolation. If only one component uses a hook, its UI is the better thing to test.
Frequently Asked Questions (FAQ) About Testing Custom Hooks
Hooks rely on React's rendering machinery to store state and run effects, so they only work while a component is rendering. Calling one directly throws an invalid hook call error. renderHook solves this by rendering a small component that calls your hook and exposes its return value.
It's exported from @testing-library/react since version 13.1. The separate @testing-library/react-hooks package was built for React 17 and earlier and is no longer maintained, so remove it when upgrading to React 18 or 19.
Wrap any code that triggers a state update outside of Testing Library's helpers, such as calling a setter returned by the hook, advancing fake timers, or dispatching DOM events manually. Rendering, rerendering, waitFor, and user-event already use act internally.
Pass a wrapper that renders a MemoryRouter with the initialEntries you need. Hooks like useParams need a matching route, so inside the wrapper render a Routes element with a Route whose path includes the parameter and whose element renders the children.
These hooks are tied closely to forms and actions, so they're usually best tested through a component that renders the form. Submit it with user-event and assert on what the user sees during and after the action, rather than inspecting the hook's return value.
jsdom provides a working localStorage, so you can write to it before rendering, render the hook, and read it back afterwards. Clear it in afterEach to keep tests isolated. To test cross-tab sync, dispatch a StorageEvent on the window inside act.
Conclusion
Testing custom hooks comes down to a small set of tools. renderHook gives you a hook's latest return value in result.current. act flushes state updates from setters, timers, and events. rerender with initialProps tests changing arguments, waitFor handles async work, wrapper supplies context, and unmount lets you verify cleanup. Combined with fake timers and MSW, that covers almost every hook you'll write.
Start with your most widely shared hook and write tests for its public contract: initial value, updates, argument changes, and cleanup. For hooks that only serve one component, test the component instead. That balance keeps your suite fast, focused on behavior, and resilient when you refactor.


