JavaScript and React course Β· Module 13: Testing React
Testing Hooks - Internal Systems Diagnostics
In this lesson7
Custom hooks are like internal spaceship systems - gravitational engine, life support system, navigation. You don't see them directly, but you need to be sure they work correctly. To test hooks, we use renderHook from React Testing Library.
You cannot simply call a hook in a test like a regular function, because React only allows hooks to be called while a component is rendering. You could write a fake component for every test, but that is a lot of repetitive code that checks nothing.
renderHook - Running a Hook in Isolation
renderHook lets you test a hook without writing your own component: the library creates a small test component itself and calls your hook inside it. The function has been part of @testing-library/react since version 13.1, and the separate @testing-library/react-hooks package is deprecated:
1import { renderHook, act } from '@testing-library/react';
2import { useCounter } from './useCounter';
3
4test('initializes counter with default value', () => {
5 const { result } = renderHook(() => useCounter());
6
7 expect(result.current.count).toBe(0);
8});
9
10test('initializes counter with custom value', () => {
11 const { result } = renderHook(() => useCounter(10));
12
13 expect(result.current.count).toBe(10);
14});We read the hook's result from result.current. The current property updates after every render, so do not destructure it into a variable at the start of the test, or you will be left with a stale value.
Besides result, the function also returns rerender and unmount. You will meet the first one in a moment, and the second unmounts the test component, so it lets you check whether the hook's effect cleans up after itself, for example by closing a connection or clearing an interval.
Testing State Changes
Hook state changes must be wrapped in act(). Here is the counter hook we are going to test:
1// useCounter.js
2function useCounter(initialValue = 0) {
3 const [count, setCount] = useState(initialValue);
4
5 const increment = () => setCount(c => c + 1);
6 const decrement = () => setCount(c => c - 1);
7 const reset = () => setCount(initialValue);
8
9 return { count, increment, decrement, reset };
10}The hook returns an object with a count value and three functions. Note that reset goes back to initialValue, not to zero. Now the test:
1test('increments and decrements counter', () => {
2 const { result } = renderHook(() => useCounter(5));
3
4 expect(result.current.count).toBe(5);
5
6 act(() => {
7 result.current.increment();
8 });
9 expect(result.current.count).toBe(6);
10
11 act(() => {
12 result.current.decrement();
13 });
14 expect(result.current.count).toBe(5);
15
16 act(() => {
17 result.current.reset();
18 });
19 expect(result.current.count).toBe(5);
20});We call the hook's functions inside act, because they change state outside RTL's tools. The last assertion confirms that reset restores the starting value of 5.
A Hook That Returns an Array
Not every hook returns an object. useToggle mimics useState and gives back a [value, toggle] pair, which a component can unpack under any names, for example const [shieldsOn, toggleShields] = useToggle():
1function useToggle(initialValue = false) {
2 const [value, setValue] = useState(initialValue);
3 const toggle = useCallback(() => setValue(v => !v), []);
4 return [value, toggle];
5}The toggle uses the functional update v => !v. With setValue(!value), two calls inside one act would both see the same stale value and the second one would not undo the first. In the test you reach the array elements by index:
1test('toggles shields on and off', () => {
2 const { result } = renderHook(() => useToggle(false));
3
4 expect(result.current[0]).toBe(false);
5
6 act(() => {
7 result.current[1]();
8 });
9 expect(result.current[0]).toBe(true);
10
11 act(() => {
12 result.current[1]();
13 });
14 expect(result.current[0]).toBe(false);
15});result.current[0] is the value and result.current[1] is the toggle function. Do not unpack them at the start of the test with const [value, toggle] = result.current, because value would stay a stale copy, just like with an object. You test a list hook that returns, for example, { items, add, remove, clear } the same way: call the action inside act and check result.current.items.
Testing Hooks with Effects
The fuel hook fetches data in useEffect, so the result appears asynchronously:
1// useFuelGauge.js
2function useFuelGauge(shipId) {
3 const [fuel, setFuel] = useState(null);
4 const [loading, setLoading] = useState(true);
5
6 useEffect(() => {
7 fetch(`/api/ships/${shipId}/fuel`)
8 .then(res => res.json())
9 .then(data => {
10 setFuel(data.level);
11 setLoading(false);
12 });
13 }, [shipId]);
14
15 return { fuel, loading };
16}The loading state starts as true and changes only after the server responds. So the test has to wait for the change:
1test('fetches fuel level for ship', async () => {
2 global.fetch = jest.fn().mockResolvedValue({
3 json: () => Promise.resolve({ level: 85 })
4 });
5
6 const { result } = renderHook(() => useFuelGauge('enterprise'));
7
8 // Initially loading
9 expect(result.current.loading).toBe(true);
10 expect(result.current.fuel).toBeNull();
11
12 // After loading
13 await waitFor(() => {
14 expect(result.current.loading).toBe(false);
15 });
16 expect(result.current.fuel).toBe(85);
17});waitFor repeats the assertion until loading becomes false. After that point we can safely check the fuel level. The global.fetch stand-in keeps the test off the network and always returns the same level of 85, so the result is repeatable.
Testing Hooks with Changing Props
When a hook depends on props, we use rerender. The initialProps option provides the arguments of the first render:
1test('refetches fuel when shipId changes', async () => {
2 global.fetch = jest.fn()
3 .mockResolvedValueOnce({
4 json: () => Promise.resolve({ level: 85 })
5 })
6 .mockResolvedValueOnce({
7 json: () => Promise.resolve({ level: 42 })
8 });
9
10 const { result, rerender } = renderHook(
11 ({ shipId }) => useFuelGauge(shipId),
12 { initialProps: { shipId: 'enterprise' } }
13 );
14
15 await waitFor(() => {
16 expect(result.current.fuel).toBe(85);
17 });
18
19 // Change shipId -- rerender with new props
20 rerender({ shipId: 'discovery' });
21
22 await waitFor(() => {
23 expect(result.current.fuel).toBe(42);
24 });
25});The two mockResolvedValueOnce calls set up responses in call order. rerender with a new shipId runs the effect again, and the hook fetches the second ship's fuel.
Testing Hooks with Context
Hooks using context require a wrapper. Here is a hook that throws an error when the provider is missing:
1// useTheme.js
2const ThemeContext = createContext();
3
4function useTheme() {
5 const context = useContext(ThemeContext);
6 if (!context) throw new Error('useTheme must be used within ThemeProvider');
7 return context;
8}Such an exception is good practice: the developer immediately knows they forgot the provider. The test checks both paths:
1test('returns theme from context', () => {
2 const wrapper = ({ children }) => (
3 <ThemeContext.Provider value={{ mode: 'dark', color: 'blue' }}>
4 {children}
5 </ThemeContext.Provider>
6 );
7
8 const { result } = renderHook(() => useTheme(), { wrapper });
9
10 expect(result.current.mode).toBe('dark');
11 expect(result.current.color).toBe('blue');
12});
13
14test('throws error without provider', () => {
15 expect(() => {
16 renderHook(() => useTheme());
17 }).toThrow('useTheme must be used within ThemeProvider');
18});The wrapper option wraps the test component in the provider. In React 19, instead of ThemeContext.Provider you can write the short <ThemeContext value={...}>. In the second test the error thrown during rendering comes back from renderHook as a regular exception, so toThrow catches it. In React 18 such a test also cluttered the console with an error message and was silenced with jest.spyOn(console, 'error'). In React 19 the console stays clean, so the spy is not needed.
Testing a useReducer Hook
A hook based on useReducer keeps its logic in a pure reducer function:
1// useMissionControl.js
2function missionReducer(state, action) {
3 switch (action.type) {
4 case 'START_MISSION':
5 return { ...state, status: 'active', mission: action.payload };
6 case 'ABORT_MISSION':
7 return { ...state, status: 'aborted', mission: null };
8 case 'COMPLETE_MISSION':
9 return { ...state, status: 'completed' };
10 default:
11 return state;
12 }
13}
14
15function useMissionControl() {
16 const [state, dispatch] = useReducer(missionReducer, {
17 status: 'idle',
18 mission: null
19 });
20
21 const startMission = (name) => dispatch({ type: 'START_MISSION', payload: name });
22 const abortMission = () => dispatch({ type: 'ABORT_MISSION' });
23 const completeMission = () => dispatch({ type: 'COMPLETE_MISSION' });
24
25 return { ...state, startMission, abortMission, completeMission };
26}The reducer takes the state and an action and returns a new state. The hook wraps dispatch in readable functions such as startMission. The test goes through the whole mission lifecycle:
1test('manages mission lifecycle', () => {
2 const { result } = renderHook(() => useMissionControl());
3
4 // Initial state
5 expect(result.current.status).toBe('idle');
6 expect(result.current.mission).toBeNull();
7
8 // Start mission
9 act(() => result.current.startMission('Apollo'));
10 expect(result.current.status).toBe('active');
11 expect(result.current.mission).toBe('Apollo');
12
13 // Complete mission
14 act(() => result.current.completeMission());
15 expect(result.current.status).toBe('completed');
16});The reducer is a pure function, so you can also test it on its own, without renderHook: pass a state and an action, then check the result.
In the next lesson you will take a "picture" of the rendered interface and learn snapshot testing.
My advice: test a hook with renderHook only when it is used across many components, and otherwise test the component that uses it. Remember: renderHook is the diagnostic bench where you check a ship system before installing it in the cockpit.
Code for this lesson: App.jsx
1import React, { useState, useReducer, useCallback } from 'react';
2
3// ===== Custom hooks (in tests you run them with renderHook) =====
4
5function useCounter(initialValue = 0) {
6 const [count, setCount] = useState(initialValue);
7 const increment = useCallback(() => setCount(c => c + 1), []);
8 const decrement = useCallback(() => setCount(c => c - 1), []);
9 const reset = useCallback(() => setCount(initialValue), [initialValue]);
10 return { count, increment, decrement, reset };
11}
12
13// A hook that returns an array [value, toggle], like useState
14function useToggle(initialValue = false) {
15 const [value, setValue] = useState(initialValue);
16 const toggle = useCallback(() => setValue(v => !v), []);
17 return [value, toggle];
18}
19
20function missionReducer(state, action) {
21 switch (action.type) {
22 case 'START_MISSION':
23 return { ...state, status: 'active', mission: action.payload };
24 case 'ABORT_MISSION':
25 return { ...state, status: 'aborted', mission: null };
26 case 'COMPLETE_MISSION':
27 return { ...state, status: 'completed' };
28 default:
29 return state;
30 }
31}
32
33function useMissionControl() {
34 const [state, dispatch] = useReducer(missionReducer, {
35 status: 'idle',
36 mission: null,
37 });
38 const startMission = (name) => dispatch({ type: 'START_MISSION', payload: name });
39 const abortMission = () => dispatch({ type: 'ABORT_MISSION' });
40 const completeMission = () => dispatch({ type: 'COMPLETE_MISSION' });
41 return { ...state, startMission, abortMission, completeMission };
42}
43
44// ===== Demo components =====
45
46const STATUS_LABELS = {
47 idle: 'idle',
48 active: 'in progress',
49 aborted: 'aborted',
50 completed: 'completed',
51};
52
53function CounterDemo() {
54 const { count, increment, decrement, reset } = useCounter(0);
55 return (
56 <div className="demo-card">
57 <h3>useCounter hook</h3>
58 <div className="counter-display">{count}</div>
59 <div className="btn-group">
60 <button onClick={decrement}>-1</button>
61 <button onClick={reset}>Reset</button>
62 <button onClick={increment}>+1</button>
63 </div>
64 </div>
65 );
66}
67
68function ToggleDemo() {
69 const [engineOn, toggleEngine] = useToggle(false);
70 const [shieldsOn, toggleShields] = useToggle(true);
71 return (
72 <div className="demo-card">
73 <h3>useToggle hook</h3>
74 <div className="toggle-row">
75 <span>Engine: {engineOn ? 'ON' : 'OFF'}</span>
76 <button onClick={toggleEngine} aria-pressed={engineOn}>Toggle engine</button>
77 </div>
78 <div className="toggle-row">
79 <span>Shields: {shieldsOn ? 'ON' : 'OFF'}</span>
80 <button onClick={toggleShields} aria-pressed={shieldsOn}>Toggle shields</button>
81 </div>
82 </div>
83 );
84}
85
86function MissionDemo() {
87 const mission = useMissionControl();
88 const [input, setInput] = useState('');
89 return (
90 <div className="demo-card">
91 <h3>useMissionControl hook</h3>
92 <p>Status: <strong>{STATUS_LABELS[mission.status]}</strong></p>
93 {mission.mission && <p>Mission: {mission.mission}</p>}
94 {mission.status !== 'active' && (
95 <div className="mission-form">
96 <input
97 value={input}
98 onChange={e => setInput(e.target.value)}
99 placeholder="Mission name..."
100 aria-label="Mission name"
101 />
102 <button
103 disabled={!input.trim()}
104 onClick={() => {
105 mission.startMission(input.trim());
106 setInput('');
107 }}
108 >
109 Start
110 </button>
111 </div>
112 )}
113 {mission.status === 'active' && (
114 <div className="btn-group">
115 <button onClick={mission.abortMission} className="danger">Abort</button>
116 <button onClick={mission.completeMission} className="success">Complete</button>
117 </div>
118 )}
119 </div>
120 );
121}
122
123export default function App() {
124 return (
125 <div className="app">
126 <h1>Testing Hooks</h1>
127 <p className="subtitle">renderHook + act() = ship system diagnostics</p>
128 <p className="note">
129 The preview shows the hooks in action. In Jest you check the same hooks
130 with renderHook, without clicking.
131 </p>
132 <CounterDemo />
133 <ToggleDemo />
134 <MissionDemo />
135 </div>
136 );
137}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What does renderHook from React Testing Library do?
Hands-on tasks in the game
- Code editor
Finish two custom hooks of the ship cargo hold. useToggle(initialValue) returns [value, toggle], and useList(initialItems) returns { items, add, remove, clear }. ___BLANK1___: toggle switches the value to the opposite one with an updater function (with a current parameter), because toggle is memoized by useCallback and does not see the new value. ___BLANK2___: add appends the item at the end of a new array. ___BLANK3___: remove(index) returns a new array without the item at the given index. Do not change the current array (push, splice): the test freezes the initial array. The test calls the hooks with renderHook and the actions with act, also two in a row in one act.
- Vertical ordering
Arrange the stages of testing a custom hook in the correct order:
- Code editor
Finish the ARIA tabs of the commander console. ___BLANK1___: every tab button has the tab role (the container already has role="tablist" and the content role="tabpanel"), so the test finds the tabs with getAllByRole('tab'). ___BLANK2___: isActive is true only for the tab whose id equals activeId; it goes into aria-selected and the style. ___BLANK3___: clicking a tab stores its id in the state. The test checks that the first tab is selected at the start, that the selection moves after a click, and that the panel shows the content of the selected tab and has its name (aria-labelledby).
- Click in order
Arrange the elements of the renderHook syntax with result in the correct order:
- Horizontal ordering
Arrange the elements for accessing a hook's return value in the correct order:
- Code editor
Finish the AlertSystem mission notification centre. The buttons add notifications of three types (info, warning, danger), each with a close button, and Clear all removes the whole list. ___BLANK1___: a new notification goes to the end of the list in a new array (the earlier ones stay). ___BLANK2___: removeAlert(id) returns a new array without the notification with the given id, and the others stay, including those of the same type. ___BLANK3___: an alarm (type danger) has the alert role, and info and warning have the status role. The test clicks the buttons, counts the elements with the alert and status roles and closes a chosen notification with its button.