Skip to content

Testing Custom Hooks

intermediate16 min read

Why Custom Hooks Need Their Own Tests

You built a useFetch hook that three components rely on. One day, a teammate refactors it. The components still render, but the hook now fires an extra network request on every keystroke. Component tests might not catch that — they test the UI, not the hook's internal behavior.

Custom hooks are reusable units of logic. They deserve their own test suite, separate from the components that consume them. Testing hooks directly lets you verify state transitions, side effects, cleanup behavior, and edge cases without the noise of a full component render.

Mental Model

Think of a custom hook like a function library that happens to use React internals. You wouldn't test a utility function by rendering a component that calls it and then checking what showed up on screen. You'd call the function directly and check the return value. renderHook gives you that same directness for hooks — call the hook, inspect what it returns, trigger updates, check again.

The renderHook API

React hooks can only run inside a component. You can't just call useState() in a test file — React will throw an error. The renderHook utility from @testing-library/react solves this by creating a thin wrapper component behind the scenes and rendering your hook inside it.

import { renderHook } from '@testing-library/react';

function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  const increment = () => setCount((c) => c + 1);
  const reset = () => setCount(initial);
  return { count, increment, reset };
}

test('starts with the initial value', () => {
  const { result } = renderHook(() => useCounter(5));

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

renderHook returns an object with a result ref. The result.current property always points to whatever your hook returned on the most recent render. This is a ref, not a snapshot — it updates as the hook re-renders.

The result.current Ref Trap

Here's something that trips people up early:

test('do NOT destructure result.current', () => {
  const { result } = renderHook(() => useCounter());

  // This captures the value at this moment in time
  const { count } = result.current;

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

  // count is still 0 — it's a stale snapshot!
  expect(count).toBe(0);

  // result.current.count reflects the latest value
  expect(result.current.count).toBe(1);
});

When you destructure result.current, you're copying primitive values at that point in time. After the hook re-renders, result.current points to the new return value, but your destructured variable is stuck on the old one.

Quiz
What happens when you destructure result.current before triggering a state update?

Testing Hooks with useState and useEffect

Most custom hooks combine state and effects. Let's test a hook that debounces a value:

function useDebouncedValue<T>(value: T, delay: number) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debounced;
}

Testing this requires controlling time. Vitest (and Jest) provide fake timers:

import { renderHook, act } from '@testing-library/react';
import { vi } from 'vitest';

beforeEach(() => {
  vi.useFakeTimers();
});

afterEach(() => {
  vi.useRealTimers();
});

test('returns the initial value immediately', () => {
  const { result } = renderHook(() => useDebouncedValue('hello', 300));

  expect(result.current).toBe('hello');
});

test('updates the value after the delay', () => {
  const { result, rerender } = renderHook(
    ({ value, delay }) => useDebouncedValue(value, delay),
    { initialProps: { value: 'hello', delay: 300 } }
  );

  rerender({ value: 'world', delay: 300 });

  // Before delay: still the old value
  expect(result.current).toBe('hello');

  act(() => {
    vi.advanceTimersByTime(300);
  });

  // After delay: updated
  expect(result.current).toBe('world');
});

test('cancels pending update when value changes', () => {
  const { result, rerender } = renderHook(
    ({ value, delay }) => useDebouncedValue(value, delay),
    { initialProps: { value: 'a', delay: 300 } }
  );

  rerender({ value: 'b', delay: 300 });

  act(() => {
    vi.advanceTimersByTime(200);
  });

  // Change again before the first debounce fires
  rerender({ value: 'c', delay: 300 });

  act(() => {
    vi.advanceTimersByTime(300);
  });

  // 'b' was never emitted — cleanup cleared the timer
  expect(result.current).toBe('c');
});

Notice the rerender function. When your hook depends on props, pass them through initialProps and use rerender with new props to simulate changes. This mirrors what happens when a parent component re-renders with different prop values.

Quiz
In the debounce test, why do we wrap vi.advanceTimersByTime inside act()?

Context Providers and the wrapper Option

Hooks that call useContext need a provider in the component tree. Without one, they'll get the default context value (or throw, if your hook requires a provider). The wrapper option lets you wrap the test component in providers:

const ThemeContext = createContext<'light' | 'dark'>('light');

function useTheme() {
  return useContext(ThemeContext);
}

test('returns the theme from context', () => {
  const wrapper = ({ children }: { children: React.ReactNode }) => (
    <ThemeContext.Provider value="dark">
      {children}
    </ThemeContext.Provider>
  );

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

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

For hooks that depend on multiple providers, compose them:

function AllProviders({ children }: { children: React.ReactNode }) {
  return (
    <ThemeContext.Provider value="dark">
      <AuthContext.Provider value={{ user: { name: 'Test' } }}>
        <QueryClientProvider client={new QueryClient()}>
          {children}
        </QueryClientProvider>
      </AuthContext.Provider>
    </ThemeContext.Provider>
  );
}

test('hook with multiple context dependencies', () => {
  const { result } = renderHook(() => useAppData(), {
    wrapper: AllProviders,
  });

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

A common pattern is to create a reusable createWrapper function that accepts overrides:

function createWrapper(overrides?: { theme?: 'light' | 'dark' }) {
  return function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <ThemeContext.Provider value={overrides?.theme ?? 'light'}>
        {children}
      </ThemeContext.Provider>
    );
  };
}

test('works with light theme', () => {
  const { result } = renderHook(() => useTheme(), {
    wrapper: createWrapper({ theme: 'light' }),
  });
  expect(result.current).toBe('light');
});

test('works with dark theme', () => {
  const { result } = renderHook(() => useTheme(), {
    wrapper: createWrapper({ theme: 'dark' }),
  });
  expect(result.current).toBe('dark');
});
Quiz
A custom hook calls useContext(AuthContext) but your test does not provide an AuthContext.Provider via the wrapper option. What happens?

Testing Async Hooks with waitFor

Hooks that fetch data or depend on async operations need waitFor to assert on values that aren't available immediately:

function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(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;
    };
  }, [url]);

  return { data, loading, error };
}
import { renderHook, waitFor } from '@testing-library/react';
import { vi } from 'vitest';

test('fetches data successfully', async () => {
  const mockData = { id: 1, name: 'Test User' };

  vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
    json: () => Promise.resolve(mockData),
  } as Response);

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

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

  // Wait for the fetch to complete
  await waitFor(() => {
    expect(result.current.loading).toBe(false);
  });

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

test('handles fetch errors', async () => {
  vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(
    new Error('Network failure')
  );

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

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

  expect(result.current.data).toBeNull();
  expect(result.current.error?.message).toBe('Network failure');
});

waitFor repeatedly runs its callback until it passes (or times out). It's polling, not magic — keep the callback focused on a single assertion that signals "the async work is done." Then assert on other values outside waitFor.

The waitFor Gotcha

Don't stuff all your assertions inside waitFor:

// Bad — if data assertion fails, you get a timeout error
// instead of a meaningful assertion failure
await waitFor(() => {
  expect(result.current.loading).toBe(false);
  expect(result.current.data).toEqual(mockData);
  expect(result.current.error).toBeNull();
});

// Good — wait for the signal, then assert everything else
await waitFor(() => {
  expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBeNull();

When you put all assertions inside waitFor, a failing data assertion causes waitFor to keep retrying until it times out. You get a cryptic timeout error instead of a clear "expected X but got Y."

Testing Hooks That Return Callbacks

When your hook returns functions (callbacks), you need act to trigger them because they typically cause state updates:

function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = () => setValue((v) => !v);
  const setOn = () => setValue(true);
  const setOff = () => setValue(false);

  return { value, toggle, setOn, setOff };
}

test('toggle flips the value', () => {
  const { result } = renderHook(() => useToggle());

  expect(result.current.value).toBe(false);

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

  expect(result.current.value).toBe(true);

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

  expect(result.current.value).toBe(false);
});

test('setOn and setOff are idempotent', () => {
  const { result } = renderHook(() => useToggle());

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

  expect(result.current.value).toBe(true);

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

  expect(result.current.value).toBe(false);
});

Every call that triggers a state update or effect must be wrapped in act. This includes:

  • Calling callbacks returned by the hook
  • Advancing fake timers (vi.advanceTimersByTime)
  • Resolving promises (when using act with async)
  • Firing events (though userEvent and fireEvent handle this internally)
Quiz
What happens if you call a hook callback that triggers setState without wrapping it in act()?

Testing Hooks with Cleanup

Hooks that set up subscriptions, event listeners, or timers need to clean up when they unmount. Test this by calling unmount from renderHook:

function useWindowResize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    const handler = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    };

    window.addEventListener('resize', handler);
    return () => window.removeEventListener('resize', handler);
  }, []);

  return size;
}

test('cleans up event listener on unmount', () => {
  const addSpy = vi.spyOn(window, 'addEventListener');
  const removeSpy = vi.spyOn(window, 'removeEventListener');

  const { unmount } = renderHook(() => useWindowResize());

  expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function));

  unmount();

  expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function));

  // Verify the same handler reference was used
  const addedHandler = addSpy.mock.calls[0][1];
  const removedHandler = removeSpy.mock.calls[0][1];
  expect(addedHandler).toBe(removedHandler);
});

That last check is subtle but important. If the hook creates a new function reference in the cleanup (a common mistake with arrow functions in the wrong place), removeEventListener gets called with a different reference than addEventListener, and the listener never actually gets removed. The handler identity check catches that bug.

Testing Interval Cleanup

function useInterval(callback: () => void, delay: number | null) {
  const savedCallback = useRef(callback);

  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    if (delay === null) return;

    const id = setInterval(() => savedCallback.current(), delay);
    return () => clearInterval(id);
  }, [delay]);
}

test('clears interval on unmount', () => {
  vi.useFakeTimers();
  const callback = vi.fn();

  const { unmount } = renderHook(() => useInterval(callback, 1000));

  act(() => {
    vi.advanceTimersByTime(3000);
  });

  expect(callback).toHaveBeenCalledTimes(3);

  unmount();

  act(() => {
    vi.advanceTimersByTime(3000);
  });

  // No additional calls after unmount
  expect(callback).toHaveBeenCalledTimes(3);

  vi.useRealTimers();
});

test('clears interval when delay becomes null', () => {
  vi.useFakeTimers();
  const callback = vi.fn();

  const { rerender } = renderHook(
    ({ delay }) => useInterval(callback, delay),
    { initialProps: { delay: 1000 as number | null } }
  );

  act(() => {
    vi.advanceTimersByTime(2000);
  });

  expect(callback).toHaveBeenCalledTimes(2);

  // Pause the interval
  rerender({ delay: null });

  act(() => {
    vi.advanceTimersByTime(5000);
  });

  // Still only 2 calls — interval was cleared
  expect(callback).toHaveBeenCalledTimes(2);

  vi.useRealTimers();
});

When to Test Hooks Directly vs Through Components

This is the question that sparks debates. Here's the practical answer:

Test the hook directly when:

  • The hook is reused across multiple components
  • The hook has complex state logic (state machines, reducers, multi-step flows)
  • The hook manages side effects that are hard to observe through the UI (cleanup, abort signals, subscription management)
  • You need to test edge cases that are hard to trigger through user interactions
  • The hook's return value contract matters (specific return types, callback stability)

Test through a component when:

  • The hook is tightly coupled to one specific component
  • The hook's behavior is fully observable through rendered output
  • Testing through the component naturally covers the hook's behavior
  • The hook is simple enough that a dedicated test adds no value

The key insight: these approaches are complementary, not competing. A shared usePagination hook deserves its own test suite that verifies page calculation logic, boundary cases, and callback behavior. The component that uses it should test that clicking "Next" shows the right content — it shouldn't re-test the pagination math.

The Testing Library Philosophy and Hooks

Kent C. Dodds, the creator of Testing Library, initially recommended against testing hooks directly. The early guidance was: "Test components, not hooks." This led to the pattern of creating a TestComponent that renders the hook's return value as text and then asserting on the screen output.

That advice made sense in a world where renderHook didn't exist in the core library. But renderHook was added to @testing-library/react in v13.1 precisely because the community needed it. The updated guidance is pragmatic: test hooks directly when it gives you better coverage with less ceremony, test through components when the UI behavior is what matters.

The anti-pattern to avoid is writing a TestComponent just to render hook values as DOM text. That's renderHook with extra steps.

Putting It All Together — Testing a Real Hook

Here's a more realistic hook and its test suite to show how the patterns combine:

function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = (value: T | ((prev: T) => T)) => {
    const valueToStore =
      value instanceof Function ? value(storedValue) : value;
    setStoredValue(valueToStore);
    window.localStorage.setItem(key, JSON.stringify(valueToStore));
  };

  const removeValue = () => {
    setStoredValue(initialValue);
    window.localStorage.removeItem(key);
  };

  return [storedValue, setValue, removeValue] as const;
}
describe('useLocalStorage', () => {
  beforeEach(() => {
    localStorage.clear();
  });

  test('returns initial value when localStorage is empty', () => {
    const { result } = renderHook(() =>
      useLocalStorage('theme', 'light')
    );

    expect(result.current[0]).toBe('light');
  });

  test('reads existing value from localStorage', () => {
    localStorage.setItem('theme', JSON.stringify('dark'));

    const { result } = renderHook(() =>
      useLocalStorage('theme', 'light')
    );

    expect(result.current[0]).toBe('dark');
  });

  test('writes to localStorage when value changes', () => {
    const { result } = renderHook(() =>
      useLocalStorage('count', 0)
    );

    act(() => {
      result.current[1](42);
    });

    expect(result.current[0]).toBe(42);
    expect(localStorage.getItem('count')).toBe('42');
  });

  test('supports functional updates', () => {
    const { result } = renderHook(() =>
      useLocalStorage('count', 10)
    );

    act(() => {
      result.current[1]((prev) => prev + 5);
    });

    expect(result.current[0]).toBe(15);
  });

  test('removes value from localStorage', () => {
    localStorage.setItem('name', JSON.stringify('Alice'));

    const { result } = renderHook(() =>
      useLocalStorage('name', 'default')
    );

    expect(result.current[0]).toBe('Alice');

    act(() => {
      result.current[2]();
    });

    expect(result.current[0]).toBe('default');
    expect(localStorage.getItem('name')).toBeNull();
  });

  test('handles corrupted localStorage data gracefully', () => {
    localStorage.setItem('data', 'not-valid-json{{{');

    const { result } = renderHook(() =>
      useLocalStorage('data', 'fallback')
    );

    expect(result.current[0]).toBe('fallback');
  });

  test('handles different keys independently', () => {
    const { result: result1 } = renderHook(() =>
      useLocalStorage('key1', 'a')
    );
    const { result: result2 } = renderHook(() =>
      useLocalStorage('key2', 'b')
    );

    act(() => {
      result1.current[1]('updated');
    });

    expect(result1.current[0]).toBe('updated');
    expect(result2.current[0]).toBe('b');
  });
});

Notice the test structure: each test is focused, descriptive, and tests one behavior. The beforeEach cleans up shared state (localStorage). Edge cases like corrupted data and key independence are covered explicitly.

Key Rules
  1. 1Always read from result.current, never destructure primitives before state updates
  2. 2Wrap every state-triggering action in act() to flush React updates synchronously
  3. 3Use waitFor for async hooks — put only the done-signal assertion inside, other assertions outside
  4. 4Use the wrapper option to provide context providers, not test-only wrapper components
  5. 5Test cleanup by calling unmount() and verifying listeners or timers were removed
  6. 6Use rerender() with new props to simulate parent re-renders, not unmount/remount
  7. 7Test hooks directly when reused or complex, through components when behavior is UI-observable
What developers doWhat they should do
Destructuring result.current into local variables and asserting on those after state changes
Destructuring copies primitive values at that point in time. After a re-render, result.current updates but your local variable is stale.
Always read result.current.propertyName directly in assertions
Putting all assertions inside waitFor for async hooks
If a non-signal assertion fails inside waitFor, it retries until timeout. You get a timeout error instead of a clear assertion failure message.
Put only the done-signal assertion in waitFor, then assert the rest outside
Creating a TestComponent that renders hook values as text, then using screen queries
A TestComponent is renderHook with extra boilerplate. renderHook gives you direct access to the hook return value without encoding and decoding through the DOM.
Use renderHook directly — it exists for this exact purpose
Calling hook callbacks outside act() and wondering why result.current is stale
Without act(), React may not flush state updates before your next assertion runs. This causes flaky tests that depend on internal React scheduling.
Wrap all calls that trigger state updates or effects in act()
Testing every custom hook directly, even simple one-liners used by a single component
A hook like useFormattedDate that just wraps a Date method adds no value as a standalone test. The component test already covers it. Save direct hook tests for reusable or complex hooks.
Test simple, single-use hooks through the component that uses them
Quiz
You have a useAuth hook used by 12 components. Where should you test the token refresh retry logic?
Quiz
Your hook calls useEffect with a cleanup function that removes an event listener. How do you verify the cleanup runs correctly?