React Curriculum

Testing Custom Hooks with renderHook & act()

Medium100% Free~25 mins#react#testing#renderHook#act#custom-hooks#headless-ui
Interview Answer (30-Second Summary)

Custom hooks must be tested within a React component execution context. React Testing Library provides renderHook to mount a test wrapper, expose result.current, and rerun hooks on rerender. Any hook functions that trigger state updates must be wrapped in act() to ensure React flushes all scheduled work before assertions.

Key Learning Objectives
✓

Test headless custom hook logic using `renderHook` from `@testing-library/react`.

✓

Explain the purpose of React `act()`: flushing state updates, effects, and microtasks before test assertions.

✓

Update hook arguments dynamically using `rerender({ initialProps })`.

✓

Assert hook unmounting cleanup functions using `unmount()`.

The Interview Problem & Context

Demonstrate how to write a unit test for a custom `useCounter` hook with initial values, increment/decrement actions, rerender prop updates, and explain why `act()` is necessary.

1import { useState, useCallback } from 'react';
2import { renderHook, act } from '@testing-library/react';
3
4// Custom Hook under test
5export function useCounter(initialCount = 0, step = 1) {
6 const [count, setCount] = useState(initialCount);
7
8 const increment = useCallback(() => setCount((c) => c + step), [step]);
9 const decrement = useCallback(() => setCount((c) => c - step), [step]);
10 const reset = useCallback(() => setCount(initialCount), [initialCount]);
11
12 return { count, increment, decrement, reset };
13}
14
15// Unit Test Suite
16describe('useCounter Custom Hook', () => {
17 test('initializes with default value and increments', () => {
18 // 1. renderHook mounts the hook in an internal test component
19 const { result } = renderHook(() => useCounter(10, 2));
20
21 expect(result.current.count).toBe(10);
22
23 // 2. Wrap state-updating actions in act()
24 act(() => {
25 result.current.increment();
26 });
27
28 // 3. result.current updates to fresh state reference
29 expect(result.current.count).toBe(12);
30 });
31
32 test('updates behavior when props change via rerender', () => {
33 const { result, rerender } = renderHook(
34 ({ init, step }) => useCounter(init, step),
35 { initialProps: { init: 0, step: 1 } }
36 );
37
38 act(() => {
39 result.current.increment();
40 });
41 expect(result.current.count).toBe(1);
42
43 // 4. Update hook parameters dynamically
44 rerender({ init: 0, step: 5 });
45
46 act(() => {
47 result.current.increment();
48 });
49 expect(result.current.count).toBe(6);
50 });
51});
Deep Architectural Explanation

How renderHook & act() Work

1. Why renderHook is Mandatory:

Hooks rely on React internal dispatcher state (Fiber node and hook linked list).
Calling useCounter() in a plain test function throws:
"Invalid hook call. Hooks can only be called inside of the body of a function component."
renderHook internally creates a dummy test component, renders it, and stores the hook's return value in result.current.

2. Why result.current Must Not Be Destructured:

typescript
// WRONG: Destructuring captures the initial value (0) forever!
const { count, increment } = renderHook(() => useCounter()).result.current;
increment();
expect(count).toBe(1); // FAILS! count is still 0 (captured by value primitive)!

// CORRECT: Always read through result.current
const { result } = renderHook(() => useCounter());
result.current.increment();
expect(result.current.count).toBe(1); // PASSES!

3. What act() Does:

act() tells React: "A user interaction or state update just occurred. Flush all scheduled re-renders, passive effects, and microtasks immediately before returning control to the test."
Without act(), your assertion might run before React has processed the state update!

Common Interview Traps & Pitfalls
✕

Destructuring `const { count } = result.current` upfront, capturing a stale primitive snapshot that never updates.

✕

Forgetting `act()` when triggering hook updater functions, causing "not wrapped in act(...)" warnings.

✕

Testing implementation state instead of the hook public return API.

Senior Interviewer Follow-Up Probes
?

How do you test an asynchronous custom hook that fetches data using `waitFor`?

?

How does `unmount()` verify that a custom hook cleans up its timers or event listeners?

?

How does React 19 change or improve the act() testing utility?

Frequently Asked Interview Questions
Why can you not call a custom hook directly in a test function?

Hooks rely on React internal Fiber tree context and dispatchers. Calling a hook outside a running React component throws an "Invalid hook call" error.

An object containing result (with result.current holding the latest return value of the hook), rerender (to pass new props), and unmount (to test cleanup effects).

act() guarantees that all React state updates, queued effects, and DOM mutations are flushed synchronously before test assertions are evaluated.

Primitives (numbers, strings, booleans) are copied by value. Destructuring captures their value at mount time and will not reflect subsequent state updates.

Pass the Context Provider as the wrapper option: renderHook(() => useTheme(), { wrapper: ThemeProvider }).

Use waitFor from @testing-library/react: await waitFor(() => expect(result.current.data).toBeDefined()).