How to test react hooks
Testing custom hooks is one of those things that feels hard until you see the pattern. Then it's straightforward.
12 Apr 2024

Testing custom hooks is one of those things that feels hard until you see the pattern. Then it's straightforward.
The challenge: hooks can't run outside of a React component. You can't just call useMyHook() in a test and assert on the result. You need a wrapper.
The Tool: renderHook
React Testing Library provides renderHook — a utility that wraps your hook in a test component so you can call it and inspect the results.
Here's a real example. I had a custom hook that fetches and validates product data using Zod schemas:
import IProduct, { productSchema } from "../../prisma/types/IProduct"
import { useEffect, useState } from "react"
function useProduct(id: string) {
const [product, setProduct] = useState<IProduct | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchProduct() {
try {
const response = await fetch(`/api/products/${id}`)
const data = await response.json()
const validated = productSchema.parse(data)
setProduct(validated)
} catch (e) {
setError(e instanceof Error ? e.message : "Unknown error")
} finally {
setLoading(false)
}
}
fetchProduct()
}, [id])
return { product, error, loading }
}
Testing It
import { renderHook, waitFor } from "@testing-library/react"
import { rest } from "msw"
import { setupServer } from "msw/node"
import useProduct from "./useProduct"
const server = setupServer(
rest.get("/api/products/:id", (req, res, ctx) => {
return res(ctx.json({ id: "1", name: "Widget", price: 9.99 }))
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test("fetches and returns product data", async () => {
const { result } = renderHook(() => useProduct("1"))
expect(result.current.loading).toBe(true)
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
expect(result.current.product).toEqual({
id: "1",
name: "Widget",
price: 9.99,
})
expect(result.current.error).toBeNull()
})
test("handles fetch errors", async () => {
server.use(
rest.get("/api/products/:id", (req, res, ctx) => {
return res(ctx.status(500))
})
)
const { result } = renderHook(() => useProduct("1"))
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
expect(result.current.error).toBeTruthy()
expect(result.current.product).toBeNull()
})
The Pattern
- Use
renderHookto run the hook in an isolated component. - Use MSW to mock API calls at the network level — no mocking fetch directly.
- Use
waitForto wait for async state updates. - Assert on
result.currentto inspect the hook's return value.
The Trade-offs
Benefits: Tests run fast. They test the hook's behavior, not its implementation. MSW mocks are realistic — they intercept actual network requests.
Costs: Setup overhead for MSW and test infrastructure. Async tests need careful handling of timing. If your hook has complex dependencies (context providers, routers), you'll need to wrap renderHook with those providers.
When to Test Hooks Directly
Test a hook directly when it contains business logic worth testing in isolation — data fetching, complex state management, validation.
Don't test a hook directly when it's just a thin wrapper around useState. Test the component that uses it instead. The hook is an implementation detail in that case.