Skip to main content

Testing React Hooks with Vitest & @testing-library/react

Custom React hooks encapsulate reusable logic. Testing them is different from testing components because hooks can't render standalone—they must run inside a component. The renderHook utility from React Testing Library lets you test hooks directly, isolating their behavior and making it easy to verify state updates, side effects, and cleanup (React Testing Library, 2026). This article teaches you to test hooks with confidence.

When to Test a Hook vs. Test a Component Using It

Test a hook directly with renderHook() when the hook's logic is complex and reusable across multiple components. Test a component that uses a hook to verify integration. Here's the decision tree:

  • Simple hook (a wrapper around useState): Test through a component.
  • Complex hook (orchestrates multiple state updates, side effects, subscriptions): Test with renderHook().
  • Hook used in one place: Test through the component using it.
  • Hook used in many places: Test with renderHook() to avoid duplication.

Setup: Install React Hooks Testing Utilities

Install @testing-library/react (includes renderHook) and @testing-library/react-hooks for older versions:

npm install -D @testing-library/react @testing-library/jest-dom vitest

In Vitest 0.31+, renderHook is in @testing-library/react directly. No separate package needed.

Basic Hook Test Pattern

Let's test a custom useCounter hook:

// hooks/useCounter.ts
import { useState } from 'react';

export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);

const increment = () => setCount((c) => c + 1);
const decrement = () => setCount((c) => c - 1);
const reset = () => setCount(initialValue);

return { count, increment, decrement, reset };
}

Test it with renderHook:

import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
it('initializes with default value of 0', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});

it('initializes with provided value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});

it('increments count', () => {
const { result } = renderHook(() => useCounter());

act(() => {
result.current.increment();
});

expect(result.current.count).toBe(1);
});

it('decrements count', () => {
const { result } = renderHook(() => useCounter(5));

act(() => {
result.current.decrement();
});

expect(result.current.count).toBe(4);
});

it('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10));

act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});

expect(result.current.count).toBe(10);
});
});

Key points:

  • renderHook() returns an object with result, which contains current—the hook's return value.
  • Wrap state updates in act() so Vitest knows to wait for React's state update batch.
  • result.current updates after act() completes.

Testing Hooks with useEffect

useEffect side effects are where many bugs hide. Test them explicitly.

// hooks/useFetch.ts
import { useEffect, useState } from 'react';

interface UseFetchResult {
data: unknown;
loading: boolean;
error: Error | null;
}

export function useFetch(url: string): UseFetchResult {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
let cancelled = false;

fetch(url)
.then((res) => res.json())
.then((json) => {
if (!cancelled) {
setData(json);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});

return () => {
cancelled = true; // Cleanup prevents state updates on unmounted hook
};
}, [url]);

return { data, loading, error };
}

Test it by mocking fetch:

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useFetch } from './useFetch';

describe('useFetch', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('starts in loading state', () => {
vi.stubGlobal('fetch', vi.fn(() =>
new Promise(() => {}) // Never resolves
));

const { result } = renderHook(() => useFetch('/api/user'));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
});

it('fetches data and updates state', async () => {
const mockData = { id: 1, name: 'Alice' };
vi.stubGlobal('fetch', vi.fn(() =>
Promise.resolve(new Response(JSON.stringify(mockData)))
));

const { result } = renderHook(() => useFetch('/api/user'));

// Initially loading
expect(result.current.loading).toBe(true);

// Wait for data
await waitFor(() => {
expect(result.current.loading).toBe(false);
});

expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
});

it('handles fetch errors', async () => {
const mockError = new Error('Network failed');
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(mockError)));

const { result } = renderHook(() => useFetch('/api/user'));

await waitFor(() => {
expect(result.current.loading).toBe(false);
});

expect(result.current.error).toEqual(mockError);
expect(result.current.data).toBe(null);
});
});

Pattern: Mock the external dependency (fetch), render the hook, wait for async updates with waitFor(), then assert the final state.

Testing Hook Dependency Updates

When a hook's dependency array changes, useEffect re-runs. Test this:

it('refetches when URL changes', async () => {
const mockFetch = vi.fn(() =>
Promise.resolve(new Response(JSON.stringify({ data: 'test' })))
);
vi.stubGlobal('fetch', mockFetch);

const { result, rerender } = renderHook(
({ url }: { url: string }) => useFetch(url),
{ initialProps: { url: '/api/user' } }
);

// First fetch
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetch).toHaveBeenCalledWith('/api/user');

// Change URL and re-render
rerender({ url: '/api/posts' });

// Wait for second fetch
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith('/api/posts');
});
});

Pattern: Use rerender() to change props, then verify useEffect re-ran with new dependencies.

Testing Hook Cleanup

Cleanup functions prevent memory leaks. Test that they run:

it('cleans up subscriptions on unmount', () => {
const unsubscribe = vi.fn();
const subscribe = vi.fn((callback: () => void) => {
callback();
return unsubscribe;
});

vi.stubGlobal('EventBus', { subscribe });

const { unmount } = renderHook(() => {
const [value, setValue] = useState(null);
useEffect(() => {
return subscribe(() => setValue('data'));
}, []);
return value;
});

expect(subscribe).toHaveBeenCalled();

unmount();

expect(unsubscribe).toHaveBeenCalled();
});

Pattern: Verify cleanup functions are called by checking mocks after unmount().

Testing Custom Context Hooks

Hooks that use useContext need a Context Provider wrapper:

it('reads from context', () => {
const TestProvider = ({ children }: { children: React.ReactNode }) => (
<ThemeContext.Provider value={{ theme: 'dark' }}>
{children}
</ThemeContext.Provider>
);

const { result } = renderHook(() => useTheme(), {
wrapper: TestProvider,
});

expect(result.current.theme).toBe('dark');
});

Pattern: Use the wrapper option to render the hook inside a Provider.

Key Takeaways

  • Use renderHook() to test complex, reusable hooks directly.
  • Always wrap state updates in act() so Vitest waits for them.
  • Test useEffect side effects by mocking external dependencies and waiting for async updates with waitFor().
  • Test dependency array changes with rerender().
  • Test cleanup functions by verifying they're called on unmount().
  • Use the wrapper option to test hooks that depend on Context providers.

Frequently Asked Questions

Do I always need to use act() around hook state updates?

Yes, when you call a function that updates state (like increment() in the counter example), wrap it in act(). However, renderHook() automatically handles state updates inside component event handlers, so if you're testing a hook through a component, act() is often unnecessary.

How do I test a hook that depends on localStorage?

Mock localStorage with vi.stubGlobal() or vi.mock(). For example:

vi.stubGlobal('localStorage', {
getItem: vi.fn(() => 'stored-value'),
setItem: vi.fn(),
removeItem: vi.fn(),
});

Then render the hook and verify it reads/writes the mocked storage.

Can I test a hook that uses useRef?

Yes. The ref value is available on result.current. For example, if your hook returns a ref, you can access result.current.inputRef.current. However, remember that refs are often implementation details—test observable behavior when possible.

Why does my hook test hang with waitFor?

waitFor() has a default timeout of 1000ms. If your hook never reaches the expected state, it will timeout. Make sure your mock is set up correctly and that you're waiting for the right state change. Use a shorter timeout for debugging: waitFor(() => {...}, { timeout: 100 }).

Should I test every hook with renderHook or test through components?

Test complex, reusable hooks with renderHook() so you have isolated, fast tests. Test hooks that are tightly coupled to one component by testing the component directly. A well-balanced test suite has both.

Further Reading