- React
- Unit Testing, Async Mocking, and Debugging Workflows
- Testing Custom Hooks with renderHook & act()
Testing Custom Hooks with renderHook & act()
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';34// Custom Hook under test5export function useCounter(initialCount = 0, step = 1) {6 const [count, setCount] = useState(initialCount);78 const increment = useCallback(() => setCount((c) => c + step), [step]);9 const decrement = useCallback(() => setCount((c) => c - step), [step]);10 const reset = useCallback(() => setCount(initialCount), [initialCount]);1112 return { count, increment, decrement, reset };13}1415// Unit Test Suite16describe('useCounter Custom Hook', () => {17 test('initializes with default value and increments', () => {18 // 1. renderHook mounts the hook in an internal test component19 const { result } = renderHook(() => useCounter(10, 2));2021 expect(result.current.count).toBe(10);2223 // 2. Wrap state-updating actions in act()24 act(() => {25 result.current.increment();26 });2728 // 3. result.current updates to fresh state reference29 expect(result.current.count).toBe(12);30 });3132 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 );3738 act(() => {39 result.current.increment();40 });41 expect(result.current.count).toBe(1);4243 // 4. Update hook parameters dynamically44 rerender({ init: 0, step: 5 });4546 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:
// 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
What does renderHook return?
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).
What is the purpose of act() in React testing?
act() guarantees that all React state updates, queued effects, and DOM mutations are flushed synchronously before test assertions are evaluated.
Why should you never destructure properties from result.current directly?
Primitives (numbers, strings, booleans) are copied by value. Destructuring captures their value at mount time and will not reflect subsequent state updates.
How do you test a custom hook that consumes Context?
Pass the Context Provider as the wrapper option: renderHook(() => useTheme(), { wrapper: ThemeProvider }).
How do you test async operations inside custom hooks?
Use waitFor from @testing-library/react: await waitFor(() => expect(result.current.data).toBeDefined()).
