0
# Hook Testing
1
2
Test custom hooks in isolation without creating wrapper components.
3
4
## API
5
6
```typescript { .api }
7
function renderHook<Result, Props>(
8
callback: (props: Props) => Result,
9
options?: RenderHookOptions<Props>
10
): RenderHookResult<Result, Props>;
11
12
interface RenderHookOptions<Props> {
13
/**
14
* Initial props passed to the hook callback
15
*/
16
initialProps?: Props;
17
18
/**
19
* Custom container element (same as render options)
20
*/
21
container?: HTMLElement;
22
23
/**
24
* Base element for queries (same as render options)
25
*/
26
baseElement?: HTMLElement;
27
28
/**
29
* Use hydration instead of normal render
30
*/
31
hydrate?: boolean;
32
33
/**
34
* Force synchronous rendering.
35
* Only supported in React 18. Not supported in React 19+.
36
* Throws an error if used with React 19 or later.
37
*/
38
legacyRoot?: boolean;
39
40
/**
41
* Wrapper component for providers
42
*/
43
wrapper?: React.JSXElementConstructor<{ children: React.ReactNode }>;
44
45
/**
46
* Enable React.StrictMode wrapper
47
*/
48
reactStrictMode?: boolean;
49
50
/**
51
* React 19+ only: Callback when React catches an error in an Error Boundary.
52
* Only available in React 19 and later.
53
*/
54
onCaughtError?: (error: Error, errorInfo: { componentStack?: string }) => void;
55
56
/**
57
* Callback when React automatically recovers from errors.
58
* Available in React 18 and later.
59
*/
60
onRecoverableError?: (error: Error, errorInfo: { componentStack?: string }) => void;
61
}
62
63
interface RenderHookResult<Result, Props> {
64
/**
65
* Stable reference to the latest hook return value.
66
* Access the current value via result.current
67
*/
68
result: {
69
current: Result;
70
};
71
72
/**
73
* Re-renders the hook with new props
74
* @param props - New props to pass to the hook callback
75
*/
76
rerender: (props?: Props) => void;
77
78
/**
79
* Unmounts the test component, triggering cleanup effects
80
*/
81
unmount: () => void;
82
}
83
```
84
85
## Common Patterns
86
87
### Basic Hook Test
88
```typescript
89
function useCounter(initial = 0) {
90
const [count, setCount] = useState(initial);
91
const increment = () => setCount(c => c + 1);
92
return { count, increment };
93
}
94
95
test('useCounter increments', () => {
96
const { result } = renderHook(() => useCounter(0));
97
98
expect(result.current.count).toBe(0);
99
100
act(() => result.current.increment());
101
expect(result.current.count).toBe(1);
102
});
103
```
104
105
### Hook with Props
106
```typescript
107
function useGreeting(name: string) {
108
return `Hello, ${name}!`;
109
}
110
111
test('useGreeting with different names', () => {
112
const { result, rerender } = renderHook(
113
({ name }) => useGreeting(name),
114
{ initialProps: { name: 'Alice' } }
115
);
116
117
expect(result.current).toBe('Hello, Alice!');
118
119
rerender({ name: 'Bob' });
120
expect(result.current).toBe('Hello, Bob!');
121
});
122
```
123
124
### Hook with Context
125
```typescript
126
function useUser() {
127
return useContext(UserContext);
128
}
129
130
test('useUser returns context value', () => {
131
const wrapper = ({ children }) => (
132
<UserContext.Provider value={{ name: 'John' }}>
133
{children}
134
</UserContext.Provider>
135
);
136
137
const { result } = renderHook(() => useUser(), { wrapper });
138
139
expect(result.current).toEqual({ name: 'John' });
140
});
141
```
142
143
### Async Hook
144
```typescript
145
function useData(url: string) {
146
const [data, setData] = useState(null);
147
const [loading, setLoading] = useState(true);
148
149
useEffect(() => {
150
fetch(url)
151
.then(res => res.json())
152
.then(data => {
153
setData(data);
154
setLoading(false);
155
});
156
}, [url]);
157
158
return { data, loading };
159
}
160
161
test('useData fetches', async () => {
162
const { result } = renderHook(() => useData('/api/user'));
163
164
expect(result.current.loading).toBe(true);
165
166
await waitFor(() => {
167
expect(result.current.loading).toBe(false);
168
});
169
170
expect(result.current.data).toBeDefined();
171
});
172
```
173
174
## Testing Patterns
175
176
### State Management Hook
177
```typescript
178
function useToggle(initial = false) {
179
const [value, setValue] = useState(initial);
180
const toggle = () => setValue(v => !v);
181
const setTrue = () => setValue(true);
182
const setFalse = () => setValue(false);
183
return { value, toggle, setTrue, setFalse };
184
}
185
186
test('useToggle manages boolean state', () => {
187
const { result } = renderHook(() => useToggle());
188
189
expect(result.current.value).toBe(false);
190
191
act(() => result.current.toggle());
192
expect(result.current.value).toBe(true);
193
194
act(() => result.current.setFalse());
195
expect(result.current.value).toBe(false);
196
});
197
```
198
199
### Effect Cleanup
200
```typescript
201
function useEventListener(event: string, handler: () => void) {
202
useEffect(() => {
203
window.addEventListener(event, handler);
204
return () => window.removeEventListener(event, handler);
205
}, [event, handler]);
206
}
207
208
test('useEventListener cleans up', () => {
209
const handler = jest.fn();
210
const { unmount } = renderHook(() => useEventListener('click', handler));
211
212
window.dispatchEvent(new Event('click'));
213
expect(handler).toHaveBeenCalledTimes(1);
214
215
unmount();
216
217
window.dispatchEvent(new Event('click'));
218
expect(handler).toHaveBeenCalledTimes(1); // Not called after unmount
219
});
220
```
221
222
### Hook with Dependencies
223
```typescript
224
function useDebounce(value: string, delay: number) {
225
const [debouncedValue, setDebouncedValue] = useState(value);
226
227
useEffect(() => {
228
const handler = setTimeout(() => setDebouncedValue(value), delay);
229
return () => clearTimeout(handler);
230
}, [value, delay]);
231
232
return debouncedValue;
233
}
234
235
test('useDebounce delays updates', async () => {
236
const { result, rerender } = renderHook(
237
({ value, delay }) => useDebounce(value, delay),
238
{ initialProps: { value: 'initial', delay: 500 } }
239
);
240
241
expect(result.current).toBe('initial');
242
243
rerender({ value: 'updated', delay: 500 });
244
expect(result.current).toBe('initial'); // Not yet updated
245
246
await waitFor(() => {
247
expect(result.current).toBe('updated');
248
}, { timeout: 1000 });
249
});
250
```
251
252
### Complex Hook with Actions
253
```typescript
254
function useList<T>(initial: T[] = []) {
255
const [items, setItems] = useState(initial);
256
257
return {
258
items,
259
add: useCallback((item: T) => setItems(prev => [...prev, item]), []),
260
remove: useCallback((index: number) =>
261
setItems(prev => prev.filter((_, i) => i !== index)), []
262
),
263
clear: useCallback(() => setItems([]), []),
264
};
265
}
266
267
test('useList manages array', () => {
268
const { result } = renderHook(() => useList(['a']));
269
270
expect(result.current.items).toEqual(['a']);
271
272
act(() => result.current.add('b'));
273
expect(result.current.items).toEqual(['a', 'b']);
274
275
act(() => result.current.remove(0));
276
expect(result.current.items).toEqual(['b']);
277
278
act(() => result.current.clear());
279
expect(result.current.items).toEqual([]);
280
});
281
```
282
283
### Hook with External Store
284
```typescript
285
function useStore() {
286
const [state, setState] = useState(store.getState());
287
288
useEffect(() => {
289
const unsubscribe = store.subscribe(setState);
290
return unsubscribe;
291
}, []);
292
293
return state;
294
}
295
296
test('useStore syncs with store', () => {
297
const { result } = renderHook(() => useStore());
298
299
expect(result.current.count).toBe(0);
300
301
act(() => store.dispatch({ type: 'INCREMENT' }));
302
303
expect(result.current.count).toBe(1);
304
});
305
```
306
307
## Important Notes
308
309
### act() Wrapping
310
Wrap state updates in act() to ensure React processes them:
311
312
```typescript
313
// ✅ Correct
314
act(() => result.current.action());
315
316
// ❌ Wrong - may have timing issues
317
result.current.action();
318
```
319
320
### result.current Stability
321
The `result` object is stable, but `result.current` updates with each render:
322
323
```typescript
324
const { result } = renderHook(() => useState(0));
325
326
// ❌ Wrong - stale reference
327
const [count, setCount] = result.current;
328
act(() => setCount(1));
329
console.log(count); // Still 0
330
331
// ✅ Correct - always use result.current
332
act(() => result.current[1](1));
333
console.log(result.current[0]); // 1
334
```
335
336
### Async Operations
337
Use waitFor for async hook updates:
338
339
```typescript
340
const { result } = renderHook(() => useAsync());
341
342
await waitFor(() => {
343
expect(result.current.isLoaded).toBe(true);
344
});
345
```
346
347
## Production Patterns
348
349
### Custom Wrapper Utility
350
```typescript
351
// test-utils.tsx
352
import { renderHook, RenderHookOptions } from '@testing-library/react';
353
354
export function renderHookWithProviders<Result, Props>(
355
callback: (props: Props) => Result,
356
options?: RenderHookOptions<Props>
357
) {
358
return renderHook(callback, {
359
wrapper: ({ children }) => (
360
<QueryClientProvider client={queryClient}>
361
<ThemeProvider>{children}</ThemeProvider>
362
</QueryClientProvider>
363
),
364
...options,
365
});
366
}
367
```
368
369
### Testing React Query Hooks
370
```typescript
371
import { renderHook, waitFor } from '@testing-library/react';
372
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
373
374
test('useUserQuery fetches user', async () => {
375
const queryClient = new QueryClient({
376
defaultOptions: { queries: { retry: false } },
377
});
378
379
const wrapper = ({ children }) => (
380
<QueryClientProvider client={queryClient}>
381
{children}
382
</QueryClientProvider>
383
);
384
385
const { result } = renderHook(() => useUserQuery('123'), { wrapper });
386
387
expect(result.current.isLoading).toBe(true);
388
389
await waitFor(() => {
390
expect(result.current.isSuccess).toBe(true);
391
});
392
393
expect(result.current.data).toEqual({ id: '123', name: 'John' });
394
});
395
```
396
397
### Testing Custom Form Hook
398
```typescript
399
function useForm(initialValues) {
400
const [values, setValues] = useState(initialValues);
401
const [errors, setErrors] = useState({});
402
403
const handleChange = (name, value) => {
404
setValues(prev => ({ ...prev, [name]: value }));
405
setErrors(prev => ({ ...prev, [name]: undefined }));
406
};
407
408
const validate = () => {
409
const newErrors = {};
410
if (!values.email) newErrors.email = 'Required';
411
setErrors(newErrors);
412
return Object.keys(newErrors).length === 0;
413
};
414
415
return { values, errors, handleChange, validate };
416
}
417
418
test('useForm validates', () => {
419
const { result } = renderHook(() => useForm({ email: '' }));
420
421
expect(result.current.errors).toEqual({});
422
423
act(() => {
424
const isValid = result.current.validate();
425
expect(isValid).toBe(false);
426
});
427
428
expect(result.current.errors).toEqual({ email: 'Required' });
429
430
act(() => result.current.handleChange('email', 'test@example.com'));
431
432
expect(result.current.errors).toEqual({});
433
434
act(() => {
435
const isValid = result.current.validate();
436
expect(isValid).toBe(true);
437
});
438
});
439
```
440
441
## Important Notes
442
443
### act() Wrapping
444
445
State updates in hooks must be wrapped in `act()` to ensure React processes updates properly:
446
447
```typescript
448
import { renderHook, act } from '@testing-library/react';
449
450
const { result } = renderHook(() => useCounter());
451
452
// Wrap state updates in act()
453
act(() => {
454
result.current.increment();
455
});
456
```
457
458
### result.current Stability
459
460
The `result` object itself is stable across renders, but `result.current` is updated with each render. Always access the latest value through `result.current`:
461
462
```typescript
463
const { result } = renderHook(() => useState(0));
464
465
// WRONG: Destructuring creates a stale reference
466
const [count, setCount] = result.current;
467
act(() => setCount(1));
468
console.log(count); // Still 0 (stale)
469
470
// CORRECT: Access via result.current
471
act(() => result.current[1](1));
472
console.log(result.current[0]); // 1 (current)
473
```
474
475
### Async Operations
476
477
Use `waitFor` for async operations:
478
479
```typescript
480
import { renderHook, waitFor } from '@testing-library/react';
481
482
const { result } = renderHook(() => useAsyncHook());
483
484
await waitFor(() => {
485
expect(result.current.isLoaded).toBe(true);
486
});
487
```
488
489
## Deprecated Types
490
491
The following type aliases are deprecated and provided for backward compatibility:
492
493
```typescript { .api }
494
/** @deprecated Use RenderHookOptions instead */
495
type BaseRenderHookOptions<Props, Q, Container, BaseElement> = RenderHookOptions<Props>;
496
497
/** @deprecated Use RenderHookOptions with hydrate: false instead */
498
interface ClientRenderHookOptions<Props, Q, Container, BaseElement> extends RenderHookOptions<Props> {
499
hydrate?: false | undefined;
500
}
501
502
/** @deprecated Use RenderHookOptions with hydrate: true instead */
503
interface HydrateHookOptions<Props, Q, Container, BaseElement> extends RenderHookOptions<Props> {
504
hydrate: true;
505
}
506
```
507
508
## Best Practices
509
510
1. **Wrap updates in act()** - All state changes must be in act()
511
2. **Access via result.current** - Never destructure result.current
512
3. **Use waitFor for async** - Don't assume timing
513
4. **Test in isolation** - Focus on hook logic, not component behavior
514
5. **Provide context** - Use wrapper for hooks needing context/providers
515
6. **Test cleanup** - Verify useEffect cleanup with unmount()
516