Skip to main content

Command Palette

Search for a command to run...

Testing Custom Hooks — renderHook and What to Actually Assert

Day 33 of 40 — React System Design Series

Updated
7 min read
Testing Custom Hooks — renderHook and What to Actually Assert
R

Hi, I’m Richa — a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons I’ve learned working on large codebases. My goal is to share practical insights — not shortcuts — for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If you’re serious about improving as an engineer, you’re in the right place.


Hey everyone, Richa here! 👋

hooks gif

Day 33.

We've spent a good chunk of this series writing custom hooks. useDebounce, useInfiniteScroll, useFeed, useAuth, useCheckout… hooks are where a lot of the real logic lives in modern React apps.

So naturally — they need tests.

But hooks can't be called outside of a React component. So how do you test them?

Enter renderHook.


The renderHook API

renderHook from React Testing Library lets you render a hook in isolation — without building a component just to test it.

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

const { result } = renderHook(() => useMyHook());

// result.current is the return value of the hook
console.log(result.current);

result is a React ref — it always points to the latest return value of the hook. After state updates, result.current reflects the new value.


Testing a simple hook — useDebounce

// hooks/useDebounce.ts
export function useDebounce<T>(value: T, delayMs: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

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

  return debouncedValue;
}
// hooks/useDebounce.test.ts
import { renderHook, act } from '@testing-library/react';
import { vi } from 'vitest';
import { useDebounce } from './useDebounce';

describe('useDebounce', () => {

  beforeEach(() => {
    vi.useFakeTimers();  // control time in tests
  });

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

  it('returns the initial value immediately', () => {
    const { result } = renderHook(() => useDebounce('hello', 300));
    expect(result.current).toBe('hello');
  });

  it('does not update the value before the delay', () => {
    const { result, rerender } = renderHook(
      ({ value }) => useDebounce(value, 300),
      { initialProps: { value: 'hello' } }
    );

    rerender({ value: 'world' });

    // Advance time but not enough to trigger debounce
    act(() => vi.advanceTimersByTime(200));

    expect(result.current).toBe('hello');  // still the old value
  });

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

    rerender({ value: 'world' });

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

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

  it('resets the timer when value changes again before delay', () => {
    const { result, rerender } = renderHook(
      ({ value }) => useDebounce(value, 300),
      { initialProps: { value: 'a' } }
    );

    rerender({ value: 'ab' });
    act(() => vi.advanceTimersByTime(200));

    rerender({ value: 'abc' });
    act(() => vi.advanceTimersByTime(200));

    // Only 200ms since last change — still not debounced
    expect(result.current).toBe('a');

    act(() => vi.advanceTimersByTime(100));

    // Now 300ms since 'abc' — debounced
    expect(result.current).toBe('abc');
  });
});

Key techniques:

  • vi.useFakeTimers() — control setTimeout in tests without waiting real time
  • rerender — update props passed to the hook
  • act() wrapping timer advances — ensures React processes state updates

Testing a hook with state — useCounter

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

  const increment = useCallback(() => setCount(c => c + 1), []);
  const decrement = useCallback(() => setCount(c => c - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);

  return { count, increment, decrement, reset };
}
// hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {

  it('starts at the initial value', () => {
    const { result } = renderHook(() => useCounter(5));
    expect(result.current.count).toBe(5);
  });

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

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

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

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

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

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

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

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

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

💡 Always wrap state-changing calls in act() — this tells React Testing Library to flush all state updates and re-renders before you assert. Without act(), result.current might reflect stale state.


Testing a hook with context — useAuth

Hooks that consume context need the provider. Pass it via the wrapper option:

// hooks/useAuth.test.tsx
import { renderHook, act } from '@testing-library/react';
import { vi } from 'vitest';
import { AuthProvider } from '@/context/AuthContext';
import { useAuth } from '@/context/AuthContext';
import { authService } from '@/services/authService';

vi.mock('@/services/authService');

const wrapper = ({ children }: { children: ReactNode }) => (
  <AuthProvider>{children}</AuthProvider>
);

describe('useAuth', () => {

  it('starts in loading state', () => {
    vi.mocked(authService.getMe).mockImplementation(() => new Promise(() => {}));
    const { result } = renderHook(() => useAuth(), { wrapper });
    expect(result.current.isLoading).toBe(true);
  });

  it('sets user after successful getMe', async () => {
    const mockUser = { id: '1', name: 'Richa', email: 'richa@example.com', role: 'editor' as const, permissions: [] };
    vi.mocked(authService.getMe).mockResolvedValue(mockUser);

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

    // Wait for the async getMe to resolve
    await act(async () => {
      await vi.runAllTimersAsync();
    });

    // Let React process the state update
    await act(async () => {});

    expect(result.current.isAuthenticated).toBe(true);
    expect(result.current.user).toEqual(mockUser);
  });

  it('sets isAuthenticated to false when getMe fails', async () => {
    vi.mocked(authService.getMe).mockRejectedValue(new Error('Not authenticated'));

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

    await act(async () => {});

    expect(result.current.isAuthenticated).toBe(false);
    expect(result.current.isLoading).toBe(false);
  });

});

When to test hooks vs components

This is a judgement call. The guidance:

Test the hook directly when:

  • The hook has significant logic that warrants isolated testing
  • The hook is reused across many components
  • The hook has complex state transitions (like a state machine)
  • Testing through a component would make the test more complex

Test through the component when:

  • The hook is simple glue between UI and a service
  • The behaviour is easier to verify via the rendered output
  • The hook is tightly coupled to one component's UI
// ✅ Test this hook directly — complex timer logic
useDebounce → test the debounce behaviour directly

// ✅ Test this hook directly — complex state machine
useCheckout → test each step transition directly

// ✅ Test through the component — glue code
useLoginForm → test the LoginForm component instead
// The form's behaviour is what matters, not the hook's internals

Testing a hook that calls an API

For hooks that fetch data, mock the service and test the states:

// hooks/useProduct.test.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import { useProduct } from './useProduct';
import { productService } from '@/services/productService';
import { QueryClientWrapper } from '@/test/test-utils';

vi.mock('@/services/productService');

describe('useProduct', () => {

  it('fetches and returns a product', async () => {
    const mockProduct = { id: '1', name: 'Widget', price: 9.99 };
    vi.mocked(productService.getById).mockResolvedValue(mockProduct);

    const { result } = renderHook(() => useProduct('1'), {
      wrapper: QueryClientWrapper,  // needs QueryClient
    });

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

    // Wait for fetch to complete
    await waitFor(() => expect(result.current.isLoading).toBe(false));

    expect(result.current.data).toEqual(mockProduct);
  });

  it('returns error state when fetch fails', async () => {
    vi.mocked(productService.getById).mockRejectedValue(new Error('Not found'));

    const { result } = renderHook(() => useProduct('999'), {
      wrapper: QueryClientWrapper,
    });

    await waitFor(() => expect(result.current.isError).toBe(true));
  });

});

waitFor — polls until the assertion passes or times out. Perfect for waiting on async state changes.


Today's takeaway

renderHook is a clean way to test hook logic in isolation. The key rules:

  1. Wrap state-changing actions in act()
  2. Pass wrapper when the hook needs providers (context, QueryClient)
  3. Use vi.useFakeTimers() for time-dependent hooks
  4. Use waitFor for hooks that fetch asynchronously
  5. Choose hook tests vs component tests based on where the logic is most clearly expressed

hooks done gif

See you tomorrow for Day 34 — Testing async code: API calls, loading states, error states.



Day 32 — React Testing Library Day 34 — Testing Async Code


Part of the React System Design Series — 40 days, one topic per day.

React System Design — Learning in Public

Part 32 of 34

I'm a React developer with 5.5 years of experience who spent 2 years in Lit.js — and slowly forgot how React really works at a senior level. This series is my public journey back. Every day I cover one topic: state management, authentication, performance, system design interviews, testing — the real stuff that separates a mid-level dev from a senior. Written honestly, with real code, for developers at every level.

Up next

Testing Async Code — API Calls, Loading States, Error States

Day 34 of 40 — React System Design Series