How to Write Effective Unit Tests for React Custom Hooks
Most frontend developers have been there: a custom hook works perfectly in the browser, but the moment you try to write a unit test for it, everything either errors out, requires mounting a dummy component, or couples your tests so tightly to implementation that one refactor wipes out a dozen green checks.
Custom hooks are not hard to test — they are just different. Once you understand the mental model, writing clean, durable hook tests becomes second nature.
Why Custom Hooks Are a Special Testing Challenge
A React custom hook is not a regular function. It relies on the React runtime to manage state, side effects, and context. Calling useSomething() directly inside a Jest test throws immediately because there is no component tree to attach to.
The naive fix — wrapping the hook in a throwaway component — works but introduces noise. Your test now asserts on rendered output rather than hook behavior, and any change to the wrapper breaks your tests for reasons that have nothing to do with the hook's logic.
The right tool for the job is renderHook from the @testing-library/react package.
Setting Up Your Test Environment
Ensure your project has the correct dependencies:
npm install --save-dev @testing-library/react @testing-library/jest-dom jest jest-environment-jsdom
In your jest.config.js, set the test environment to jsdom so the React runtime behaves correctly:
// jest.config.js
module.exports = {
testEnvironment: "jsdom",
setupFilesAfterFramework: ["@testing-library/jest-dom"],
};
With that in place, you have access to renderHook and act — the two utilities that do most of the heavy lifting.
The Core Pattern: renderHook and act
Consider a straightforward hook that manages a toggle state:
// useToggle.ts
import { useState, useCallback } from "react";
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((prev) => !prev), []);
const reset = useCallback(() => setOn(initial), [initial]);
return { on, toggle, reset };
}
Here is how you test it properly:
// useToggle.test.ts
import { renderHook, act } from "@testing-library/react";
import { useToggle } from "./useToggle";
describe("useToggle", () => {
it("initialises with the provided value", () => {
const { result } = renderHook(() => useToggle(true));
expect(result.current.on).toBe(true);
});
it("toggles state on each call", () => {
const { result } = renderHook(() => useToggle());
act(() => result.current.toggle());
expect(result.current.on).toBe(true);
act(() => result.current.toggle());
expect(result.current.on).toBe(false);
});
it("resets to initial value", () => {
const { result } = renderHook(() => useToggle(true));
act(() => result.current.toggle()); // now false
act(() => result.current.reset());
expect(result.current.on).toBe(true);
});
});
Two rules to internalize here:
- Always read state from
result.currentafter interactions, not from a snapshot taken before. - Always wrap state-mutating calls in
act(). Skipping it causes React to warn about unhandled state updates and can produce false results.
Mocking External Dependencies
Real-world hooks rarely operate in a vacuum. They fetch data, read from context, call third-party SDKs, or interact with browser APIs. Testing these in isolation requires clean mocking.
Mocking a fetch call
Suppose your hook calls fetch internally. Use jest.spyOn or assign a mock directly to global.fetch:
beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: 1, name: "Accra" }),
});
});
afterEach(() => {
jest.restoreAllMocks();
});
Now when renderHook runs your data-fetching hook, it hits the mock, not the network. Your test is fast, deterministic, and offline-friendly.
Mocking a context dependency
If your hook reads from a React context, pass a custom wrapper to renderHook:
const wrapper = ({ children }) => (
<AuthContext.Provider value={{ userId: "abc123" }}>
{children}
</AuthContext.Provider>
);
const { result } = renderHook(() => useUserProfile(), { wrapper });
This is far cleaner than building a full component tree and lets you vary context values per test case without duplicating setup.
Asserting State Transitions Without Coupling to Implementation
The most common mistake in hook testing is asserting on internal implementation details — checking whether a specific setState call happened, or verifying the shape of an intermediate variable that is never exposed to consumers.
Test the contract, not the internals. A hook's contract is what it returns and what side effects it triggers. Ask:
- Does the returned value match what a consuming component would receive?
- Does calling the returned functions produce the expected next state?
- Do async operations eventually settle to the right output?
For async hooks, use waitFor from @testing-library/react to avoid flaky assertions:
import { renderHook, act, waitFor } from "@testing-library/react";
it("loads user data after mount", async () => {
const { result } = renderHook(() => useUserProfile("abc123"));
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.user.name).toBe("Accra");
});
This pattern is resilient to timing changes in your hook's internals. You are asserting that loading eventually resolves — not that it resolves within an arbitrary timeout or after a fixed number of ticks.
Practical Tips for Durable Hook Tests
- Name tests by behavior, not implementation.
"returns false when toggle is called twice"ages better than"calls setOn with false". - Test edge cases explicitly. Empty arrays, null values, and rapid successive calls are where hook bugs live.
- Keep each test focused. One assertion per state transition makes failures easy to diagnose.
- Reset mocks between tests using
jest.clearAllMocks()inbeforeEachto prevent state leaking across cases. - Avoid re-exporting hooks just for tests. If a hook is difficult to test, that is usually a signal it is doing too much — consider splitting it.
Why This Matters for Your Project
Well-tested custom hooks are the backbone of a maintainable React codebase. As your SaaS product grows and hooks get reused across multiple features, a solid test suite acts as a safety net that lets your team refactor confidently, onboard new engineers faster, and ship without regressions. The investment in proper renderHook patterns and clean dependency mocking pays compounding dividends from the first sprint onward.





