How to Test Custom Hooks with React Testing Library

If you've built the hooks from How to Build a Custom Hook That Actually Earns Its Abstraction — useDebounce, useLocalStorage, useIntersectionObserver — you have logic worth protecting. Logic that could break silently when you refactor. Logic that a test suite should catch before production does.
The instinct most developers have is to test hooks through a component. Render a component that uses the hook. Interact with the component. Assert the outcome.
That works. But it couples your hook tests to your UI decisions. If you rename a button or change a class, the test breaks — even though the hook logic is fine. The test is testing too much.
renderHook from React Testing Library lets you test the hook directly. No component. No UI. Just the hook's inputs and outputs.
Why hooks need special treatment
React enforces the rules of hooks — they can only be called inside a component or another hook. You can't call useDebounce directly in a test file:
renderHook solves this by creating a minimal host component behind the scenes — one that calls your hook and exposes the result. You never see the component. You just interact with the hook's return value.
result.current always holds the latest value returned by the hook. When the hook updates, result.current updates too.
The setup
In your vitest.config.ts:
In src/test/setup.ts:
That's the baseline. Now let's write three real hook tests.
Test 1: useDebounce
The hook from How to Build a Custom Hook That Actually Earns Its Abstraction:
Three things to test:
- Returns the initial value immediately
- Doesn't update until the delay has passed
- Updates correctly after the delay
Three patterns worth noting:
rerender — updates the props passed to the hook without unmounting and remounting it. Use this whenever you need to simulate a prop change mid-test.
vi.useFakeTimers() and vi.advanceTimersByTime() — fake timers let you control time in tests. advanceTimersByTime(300) skips 300ms instantly, triggering any setTimeout callbacks scheduled within that window.
act() — wraps any operation that causes state updates. Timer advancement triggers effects and state updates, so it needs act. React Testing Library wraps most interactions in act automatically, but manual timer control requires explicit wrapping.
Test 2: useLocalStorage
Testing a hook that interacts with localStorage requires mocking the browser API. jsdom provides localStorage in the test environment, but it persists between tests — which can cause unexpected failures.
The last test is the one that catches real bugs. Invalid JSON in localStorage — from a previous version of your app, from a browser extension, from a user manually editing storage — should never crash the hook. Testing the error path explicitly is what separates a hook that earns its abstraction from one that breaks in production.
Test 3: a hook with async state
When a hook fetches data, tests need to wait for the async operation to resolve. waitFor polls until an assertion passes or a timeout expires.
waitFor is the key primitive for async hook testing. It retries the assertion repeatedly until it passes or the timeout (default 1000ms) expires. Any state updates triggered by the async resolution are automatically wrapped in act.
The last test — verifying that changing userId triggers a new fetch — is the test that would have caught the race condition described in Article #1. Different userId, different fetch, correct data for the correct user.
The rule: test behaviour, not implementation
The tests above assert what the hook does, not how it does it.
They don't assert that useState was called with a specific value. They don't check that clearTimeout ran. They don't verify internal implementation details that could change tomorrow without breaking the hook's contract.
If you refactor useLocalStorage to use useReducer internally instead of useState, none of these tests break — because they're asserting the hook's external contract, not its internal wiring.
That's what makes a test suite maintainable at scale. Tests that survive refactors aren't just convenient — they're the tests you actually trust.
Up next: TypeScript generics in React — the patterns you'll use every week — the generic component and hook patterns that eliminate prop type duplication.
Related: How to test a multi-tenant application without data leaking between tenants by Mira Halsted — hook testing patterns applied to the integration layer.
Alex Chen is a senior frontend engineer who writes about React patterns, JavaScript internals, and the decisions that separate maintainable codebases from ones that fight back. Opinionated by design.
Photo by Adam Bezer on Unsplash

