or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

Files

docs

async-testing.mdasync.mdcomponent-rendering.mdconfiguration.mdelement-queries.mdevent-simulation.mdevents.mdhook-testing.mdhooks.mdindex.mdqueries.mdquick-reference.mdrendering.md

hooks.mddocs/

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