Skip to main content

⏱️ Testing Hooks & Async Code

Some of your code doesn't run instantly. A component fetches a user, a hook debounces a search box, a timer counts down. Testing these is like being a detective in a time-travel mystery — you care not just about what happens but when. This lesson gives you the three tools that make time-based testing reliable: renderHook, RTL's async utilities, and fake timers.

Week 5 · Day 5 (Friday: Testing React Components) · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Test a custom hook in isolation with renderHook and read state via result.current
  • Wrap state-changing calls in act() and explain why the warning appears
  • Wait for asynchronous UI with findBy* queries and waitFor
  • Mock fetch so async components resolve deterministically
  • Control time with Jest fake timers to test setTimeout/setInterval logic
  • Verify a hook re-runs when its inputs change and cleans up on unmount

Estimated Time: 75 minutes

Practice: Test a useCounter hook, an async useUser hook, and a countdown timer.

In This Lesson

The Time Problem

In the last lesson every assertion ran the instant after an interaction. That works because rendering and clicking are synchronous. But the moment your code awaits a network response or schedules a timer, the value you want to assert on doesn't exist yet when your test reaches the assertion. Assert too early and you're checking a loading spinner; assert too late and the test is slow and flaky.

Three tools solve three flavors of "over time":

graph TD A[Time-based code] --> B[Custom hooks] A --> C[Async operations] A --> D[Timers] B --> E[renderHook + act] C --> F[findBy / waitFor] D --> G[fake timers] E --> H[Reliable test] F --> H G --> H

We'll take them one at a time, then combine all three in an end-to-end async-hook test.

Testing Hooks with renderHook

A custom hook can only run inside a React component — call it in a plain test and React throws "invalid hook call." RTL's renderHook hosts your hook inside a throwaway component for you and hands back a result whose .current property always reflects the hook's latest return value.

// useCounter.js — the hook under test
import { useState, useCallback } from 'react';

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 };
}
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  test('starts at the default value', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  test('accepts a custom initial value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

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

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

    expect(result.current.count).toBe(1);
  });
});
renderHook mounts a hook inside a host component and exposes result.current renderHook mounts a hidden host component that calls your hook result.current the hook's latest return value { count, increment } act() flush state updates before you assert
renderHook gives you result.current; wrap any call that changes state in act() before reading it back.

act() & State Updates

React batches state updates and re-renders. act() tells React "run everything this triggers, then settle" so that by the time act returns, result.current holds the updated value. Call a state setter outside act and you'll get the familiar warning — and possibly a stale read.

// ❌ Warning: "An update to TestComponent was not wrapped in act(...)"
test('problematic', () => {
  const { result } = renderHook(() => useCounter());
  result.current.increment();          // state change escapes act
  expect(result.current.count).toBe(1); // may still read 0
});

// ✅ Wrap the state-changing call
test('correct', () => {
  const { result } = renderHook(() => useCounter());
  act(() => {
    result.current.increment();
  });
  expect(result.current.count).toBe(1);
});

📖 Sync act vs async act

Use synchronous act(() => { ... }) for plain state setters. When the code inside performs asynchronous work you need to flush (an awaited fetch, a resolved promise), use the async form: await act(async () => { await result.current.load(); }). Good news: interactions through userEvent and RTL's waitFor/findBy wrap act for you, so you rarely call it by hand outside hook tests.

Waiting: findBy & waitFor

When a component updates after an async operation, you need to wait for the DOM to catch up. RTL gives you two complementary tools.

findBy* — wait for an element to appear

A findBy query is getBy + retry. It polls until the element shows up (default timeout ~1000ms) and returns a Promise, so you await it.

// waits up to ~1s for the heading to appear after the fetch resolves
const name = await screen.findByRole('heading', { name: 'Ada Lovelace' });
expect(name).toBeInTheDocument();

waitFor — wait for any condition

When you're waiting on something that isn't "an element appeared" — a value changed, a mock was called, an element disappeared — wrap the assertion in waitFor. It re-runs the callback until it stops throwing.

// wait for the loading spinner to be removed
await waitFor(() => {
  expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});

// wait for a hook value to settle
await waitFor(() => expect(result.current.loading).toBe(false));

⚠️ Keep waitFor callbacks small

Put a single assertion inside waitFor and never side effects (no clicks, no state changes) — the callback runs many times. Prefer a findBy query when you're simply waiting for an element to render; reach for waitFor for everything else.

Mocking fetch

Tests must be fast and deterministic, so they never hit a real network. Replace fetch with a Jest mock that resolves the data you choose. Reset it between tests so mocks don't leak.

beforeEach(() => {
  global.fetch = jest.fn();
});
afterEach(() => {
  jest.restoreAllMocks();
});

// In a test: queue one successful response
global.fetch.mockResolvedValueOnce({
  ok: true,
  json: async () => ({ id: 1, name: 'Ada Lovelace' }),
});

// Or a rejection, to exercise the error path
global.fetch.mockRejectedValueOnce(new Error('Network error'));

💡 MSW for bigger apps

Hand-mocking fetch is perfect for a single hook. For app-wide integration tests you'll graduate to Mock Service Worker (MSW), which intercepts requests at the network layer so your code calls real fetch — you'll meet it in the next lesson on integration testing.

Async Hooks End-to-End

Now combine everything. Here's a data-fetching hook and a test suite covering the loading state, the success path, an error, and a refetch when the input changes.

// useUser.js
import { useState, useEffect, useCallback } from 'react';

export function useUser(userId) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const fetchUser = useCallback(async () => {
    try {
      setLoading(true);
      setError(null);
      const res = await fetch(`/api/users/${userId}`);
      if (!res.ok) throw new Error('Failed to fetch user');
      setUser(await res.json());
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, [userId]);

  useEffect(() => { fetchUser(); }, [fetchUser]);

  return { user, loading, error, refetch: fetchUser };
}
import { renderHook, waitFor } from '@testing-library/react';
import { useUser } from './useUser';

describe('useUser', () => {
  beforeEach(() => { global.fetch = jest.fn(); });
  afterEach(() => { jest.restoreAllMocks(); });

  test('fetches a user successfully', async () => {
    const mockUser = { id: 1, name: 'Ada Lovelace' };
    global.fetch.mockResolvedValueOnce({ ok: true, json: async () => mockUser });

    const { result } = renderHook(() => useUser(1));

    // starts loading
    expect(result.current.loading).toBe(true);
    expect(result.current.user).toBe(null);

    // wait for the fetch to settle
    await waitFor(() => expect(result.current.loading).toBe(false));

    expect(result.current.user).toEqual(mockUser);
    expect(result.current.error).toBe(null);
  });

  test('captures a fetch error', async () => {
    global.fetch.mockRejectedValueOnce(new Error('Network error'));

    const { result } = renderHook(() => useUser(1));

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

    expect(result.current.error).toBe('Network error');
    expect(result.current.user).toBe(null);
  });

  test('refetches when userId changes', async () => {
    const u1 = { id: 1, name: 'User One' };
    const u2 = { id: 2, name: 'User Two' };
    global.fetch
      .mockResolvedValueOnce({ ok: true, json: async () => u1 })
      .mockResolvedValueOnce({ ok: true, json: async () => u2 });

    const { result, rerender } = renderHook(
      ({ id }) => useUser(id),
      { initialProps: { id: 1 } }
    );

    await waitFor(() => expect(result.current.user).toEqual(u1));

    rerender({ id: 2 });   // change the input

    await waitFor(() => expect(result.current.user).toEqual(u2));
  });
});

The rerender call from renderHook is how you test dependency changes — it re-runs the hook with new props, exactly as a parent re-render would. Combined with mocked responses and waitFor, you've verified a hook's full lifecycle without a real server.

Fake Timers

Testing a three-second countdown by actually waiting three seconds would make your suite crawl. Jest's fake timers replace setTimeout/setInterval with a clock you control, so you can fast-forward time instantly with jest.advanceTimersByTime(ms).

// useCountdown.js
import { useState, useEffect, useCallback } from 'react';

export function useCountdown(initialSeconds) {
  const [seconds, setSeconds] = useState(initialSeconds);
  const [isRunning, setIsRunning] = useState(false);

  useEffect(() => {
    if (!isRunning || seconds <= 0) return;
    const id = setInterval(() => {
      setSeconds((prev) => {
        if (prev <= 1) { setIsRunning(false); return 0; }
        return prev - 1;
      });
    }, 1000);
    return () => clearInterval(id);
  }, [isRunning, seconds]);

  const start = useCallback(() => setIsRunning(true), []);
  const pause = useCallback(() => setIsRunning(false), []);
  const reset = useCallback(() => {
    setSeconds(initialSeconds); setIsRunning(false);
  }, [initialSeconds]);

  return { seconds, isRunning, start, pause, reset };
}
import { renderHook, act } from '@testing-library/react';
import { useCountdown } from './useCountdown';

describe('useCountdown', () => {
  beforeEach(() => { jest.useFakeTimers(); });
  afterEach(() => { jest.useRealTimers(); });

  test('counts down while running, and pause freezes it', () => {
    const { result } = renderHook(() => useCountdown(5));
    expect(result.current.seconds).toBe(5);

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

    // fast-forward 3 seconds
    act(() => { jest.advanceTimersByTime(3000); });
    expect(result.current.seconds).toBe(2);

    // pause, then advance — value should hold
    act(() => { result.current.pause(); });
    act(() => { jest.advanceTimersByTime(2000); });
    expect(result.current.seconds).toBe(2);

    act(() => { result.current.reset(); });
    expect(result.current.seconds).toBe(5);
    expect(result.current.isRunning).toBe(false);
  });
});

⚠️ Fake timers + userEvent

userEvent adds its own internal delays, which fake timers freeze. When you use both together, configure userEvent to advance the fake clock: userEvent.setup({ advanceTimers: jest.advanceTimersByTime }). And always call jest.useRealTimers() in afterEach so fake timers don't leak into other test files.

Practice & Quiz

🏋️ Exercise 1: A debounce hook

Goal: Test the useDebounce hook below. Prove that changing the value doesn't update immediately, but does after the delay elapses. Use fake timers and rerender.

export function useDebounce(value, delay) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}
💡 Hint

Render with initialProps: { value: 'a', delay: 500 }. Call rerender({ value: 'b', delay: 500 }), assert the value is still 'a', then act(() => jest.advanceTimersByTime(500)) and assert it's now 'b'.

✅ Solution
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './useDebounce';

beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());

test('debounces value changes', () => {
  const { result, rerender } = renderHook(
    ({ value, delay }) => useDebounce(value, delay),
    { initialProps: { value: 'a', delay: 500 } }
  );

  expect(result.current).toBe('a');

  rerender({ value: 'b', delay: 500 });
  expect(result.current).toBe('a');       // not yet

  act(() => { jest.advanceTimersByTime(500); });
  expect(result.current).toBe('b');       // after the delay
});

🏋️ Exercise 2: Cleanup on unmount

Goal: Given a hook that adds a window event listener in useEffect and removes it in the cleanup, write a test proving the listener is removed when the hook unmounts. renderHook returns an unmount function.

✅ Solution
test('removes the listener on unmount', () => {
  const element = {
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
  };
  const { unmount } = renderHook(() =>
    useEventListener('click', jest.fn(), element)
  );

  expect(element.addEventListener)
    .toHaveBeenCalledWith('click', expect.any(Function));

  unmount();

  expect(element.removeEventListener)
    .toHaveBeenCalledWith('click', expect.any(Function));
});

🎯 Quick Quiz

Question 1: You call a hook's increment() in a test and read result.current.count right after, but it's stale and you see an act warning. What's missing?

Question 2: A component shows data after an awaited fetch. Which is the cleanest way to wait for the heading to appear?

Question 3: Why use jest.useFakeTimers() when testing a countdown?

Best Practices & Pitfalls

✅ Do

  • Wrap synchronous state setters in act(); use await act(async …) for async work in hooks
  • Prefer findBy* for "element appears" and waitFor for other conditions
  • Mock fetch (or use MSW) so tests never touch the real network
  • Reset mocks and timers in afterEach: jest.restoreAllMocks(), jest.useRealTimers()
  • Test the error path, not just the happy path

❌ Don't

  • Read result.current immediately after a state change without act()
  • Put side effects (clicks, setState) inside a waitFor callback — it runs repeatedly
  • Rely on fixed setTimeout delays to "wait" — use waitFor/findBy instead
  • Leave fake timers enabled — they leak into other tests

✅ A reliable async setup block

beforeEach(() => {
  jest.useFakeTimers();
  global.fetch = jest.fn();
});
afterEach(() => {
  jest.runOnlyPendingTimers();
  jest.useRealTimers();
  jest.restoreAllMocks();
});

Summary

🎉 Key Takeaways

  • renderHook tests a hook in isolation; read its output through result.current
  • Wrap state-changing calls in act() so React settles before you assert
  • Use findBy* to wait for an element and waitFor to wait for any condition
  • Mock fetch for deterministic async tests; graduate to MSW for whole apps
  • Fake timers let you fast-forward setTimeout/setInterval instantly
  • Test loading, success, and error states, plus cleanup on unmount

📚 Additional Resources

🚀 What's Next?

You can now test hooks and async behavior in isolation. The final testing lesson zooms out to whole features working together — multiple components, shared context, and realistic network mocking: Integration Testing.

⏱️ Time mastered!

Async code and hooks used to be the scary part of testing. Now they're just three tools you know how to reach for.