Structuring an app all 4 written
Context, reducers, custom hooks, and the rules of hooks, broken on purpose so you can read what React says.
18 Context
Passing a value down through five components that do not use it, only so a sixth can, is tiring and fragile. Context is the other way to get a value to a component: put it on the tree above, and any component below can read it. This lesson makes a context, overrides it, and then counts what a change costs, because that cost is the thing to know before you use it.
A default, and a provider that overrides it
createContext(default) makes the context. A component reads the nearest value with
useContext. With no provider above it, the reader gets the default. A provider wraps part
of the tree and sets a value for everything inside it, and a provider inside another overrides it
for its own part only. Since React 19 the context itself is the provider: write
<ThemeContext value="dark">, with no .Provider.
theme.jsx
import { createContext, useContext, act } from 'react';
import { createRoot } from 'react-dom/client';
const ThemeContext = createContext('light');
function Label({ name }) {
const theme = useContext(ThemeContext);
return <p>{name}: {theme}</p>;
}
const root = createRoot(stage);
await act(async () =>
root.render(
<>
<Label name="none" />
<ThemeContext value="dark">
<Label name="outer" />
<ThemeContext value="contrast">
<Label name="inner" />
</ThemeContext>
<Label name="again" />
</ThemeContext>
<ThemeContext.Provider value="dark">
<Label name="old way" />
</ThemeContext.Provider>
</>,
),
);
const lines = Array.from(stage.querySelectorAll('p')).map((p) => p.textContent);
console.log(lines);
check('no provider: the default', lines[0], 'none: light');
check('a provider sets the value below it', lines[1], 'outer: dark');
check('a nested provider overrides it', lines[2], 'inner: contrast');
check('and only for its own subtree', lines[3], 'again: dark');
check('.Provider still works and reads the same', lines[4], 'old way: dark');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node theme.jsx
[ 'none: light', 'outer: dark', 'inner: contrast', 'again: dark', 'old way: dark' ] ok no provider: the default ok a provider sets the value below it ok a nested provider overrides it ok and only for its own subtree ok .Provider still works and reads the same
Who re-renders when the value changes
When a provider's value changes, React re-renders every component that read that context, however
deep. It does not re-render the components in between just because a value passed through them. In
the panel Page is passed to Theme as children, so it is the
same element on every render of Theme and React skips it (lesson 22 explains why). Only
Reader, which called useContext, renders again.
readers.jsx
import { createContext, useContext, useState, act } from 'react';
import { createRoot } from 'react-dom/client';
const ThemeContext = createContext('light');
const renders = { page: 0, reader: 0 };
function Reader() {
renders.reader += 1;
return <b>{useContext(ThemeContext)}</b>;
}
function Page() {
renders.page += 1;
return <Reader />;
}
function Theme({ children }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext value={theme}>
{children}
<button onClick={() => setTheme('dark')}>go dark</button>
</ThemeContext>
);
}
const root = createRoot(stage);
await act(async () => root.render(<Theme><Page /></Theme>));
const before = { ...renders };
const textBefore = stage.querySelector('b').textContent;
await act(async () => stage.querySelector('button').click());
const textAfter = stage.querySelector('b').textContent;
console.log(before, renders, textBefore, textAfter);
check('the reader showed the old value', textBefore, 'light');
check('and the new one after the change', textAfter, 'dark');
check('the reader rendered again', renders.reader - before.reader, 1);
check('the component between did not', renders.page - before.page, 0);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node readers.jsx
{ page: 1, reader: 1 } { page: 1, reader: 2 } light dark
ok the reader showed the old value
ok and the new one after the change
ok the reader rendered again
ok the component between did not
The object you write in the provider
The usual value is an object, { user, signOut }. An object written in the component
is a new object every time it renders, and React compares context values with
Object.is, so a new object counts as a change even when every field is the same. Any
state change in the provider then re-renders every reader. Memoise the object with
useMemo (lesson 23) and the readers rest until something they use really changes.
identity.jsx
import { createContext, useContext, useMemo, useState, act } from 'react';
import { createRoot } from 'react-dom/client';
const UserContext = createContext(null);
const renders = { loose: 0, steady: 0 };
function LooseReader() {
renders.loose += 1;
return <i>{useContext(UserContext).name}</i>;
}
function SteadyReader() {
renders.steady += 1;
return <i>{useContext(UserContext).name}</i>;
}
function Loose({ children }) {
const [clicks, setClicks] = useState(0);
return (
<UserContext value={{ name: 'Ada' }}>
{children}
<button onClick={() => setClicks(clicks + 1)}>clicks {clicks}</button>
</UserContext>
);
}
function Steady({ children }) {
const [clicks, setClicks] = useState(0);
const user = useMemo(() => ({ name: 'Ada' }), []);
return (
<UserContext value={user}>
{children}
<button onClick={() => setClicks(clicks + 1)}>clicks {clicks}</button>
</UserContext>
);
}
async function clickOnce(tree) {
const root = createRoot(stage);
await act(async () => root.render(tree));
await act(async () => stage.querySelector('button').click());
await act(async () => root.unmount());
}
await clickOnce(<Loose><LooseReader /></Loose>);
await clickOnce(<Steady><SteadyReader /></Steady>);
console.log(renders);
check('a new object each render: the reader rendered again', renders.loose, 2);
check('the same object: the reader rendered once', renders.steady, 1);
What node printed (React 19.2.7, development build), when the build ran it
node identity.jsx
{ loose: 2, steady: 1 }
ok a new object each render: the reader rendered again
ok the same object: the reader rendered once
use() can read context after a condition
useContext is a hook, so it must run on every render in the same order (lesson 21).
React 19's use can read a context too, and unlike a hook it may be called after an early
return or inside an if. When the component returns early, it does not read
the context at all.
use-context.jsx
import { createContext, use, act } from 'react';
import { createRoot } from 'react-dom/client';
const ThemeContext = createContext('light');
function Panel({ open }) {
if (!open) return <p>closed</p>;
const theme = use(ThemeContext);
return <p>open, {theme}</p>;
}
const root = createRoot(stage);
const seen = [];
await act(async () => root.render(<ThemeContext value="dark"><Panel open={false} /></ThemeContext>));
seen.push(stage.textContent);
await act(async () => root.render(<ThemeContext value="dark"><Panel open={true} /></ThemeContext>));
seen.push(stage.textContent);
console.log(seen);
check('closed: the context was never read', seen[0], 'closed');
check('open: use() read it after the early return', seen[1], 'open, dark');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node use-context.jsx
[ 'closed', 'open, dark' ] ok closed: the context was never read ok open: use() read it after the early return
When context fits. Use it for values many components at different depths need and that change rarely: the theme, the signed-in user, the language. It is not a store for fast-changing state, because every reader renders when the value does. For a value only a few levels down, props are clearer.
createContext(default); a reader with no provider above gets the default.- A provider overrides the value for its own subtree; a nested one overrides the outer.
- In React 19, write
<Ctx value>;.Providerstill works. - A changed value re-renders the readers, and only the readers.
- An object literal as the value changes every render; memoise it.
use(Ctx)may be called after a condition;useContextmay not.
19 useReducer
A component with a lot of state updates ends up with a lot of handlers,
each changing the state in its own way. A reducer collects the ways into one function:
(state, action) => newState. Components describe what happened by
dispatching an action, and the reducer decides what the new state is.
The reducer is just a function
A reducer takes the current state and an action and returns the next state. It does not change the state it was given, does not call anything outside itself and does not depend on time or chance: the same inputs give the same output. That makes it testable with no React at all. The panel below imports it and checks each action, including that the old state is untouched and that an action it does not know is an error.
cart.js
export const initialCart = { items: [] };
export function cartReducer(state, action) {
switch (action.type) {
case 'add':
return { ...state, items: [...state.items, action.item] };
case 'remove':
return { ...state, items: state.items.filter((item) => item !== action.item) };
case 'clear':
return { ...state, items: [] };
default:
throw new Error('Unknown action: ' + action.type);
}
}
cart.test.mjs
import { cartReducer, initialCart } from './cart.js';
const one = cartReducer(initialCart, { type: 'add', item: 'tea' });
const two = cartReducer(one, { type: 'add', item: 'milk' });
const three = cartReducer(two, { type: 'remove', item: 'milk' });
const four = cartReducer(three, { type: 'clear' });
console.log(one, two, three, four);
check('add puts the item in a new state', one, { items: ['tea'] });
check('add again', two, { items: ['tea', 'milk'] });
check('remove takes one out', three, { items: ['tea'] });
check('clear empties it', four, { items: [] });
check('the starting state was not changed', initialCart, { items: [] });
check('the earlier state was not changed either', two.items, ['tea', 'milk']);
check('a new object each time', one !== initialCart, true);
let message = '';
try {
cartReducer(initialCart, { type: 'explode' });
} catch (e) {
message = e.message;
}
check('an unknown action is an error', message, 'Unknown action: explode');
What node printed (React 19.2.7, development build), when the build ran it
node cart.test.mjs
{ items: [ 'tea' ] } { items: [ 'tea', 'milk' ] } { items: [ 'tea' ] } { items: [] }
ok add puts the item in a new state
ok add again
ok remove takes one out
ok clear empties it
ok the starting state was not changed
ok the earlier state was not changed either
ok a new object each time
ok an unknown action is an error
Driving it from a component
useReducer(reducer, initialState) returns the state and a dispatch
function. Calling dispatch(action) asks React to run the reducer with the current state
and that action, then render with the result. The reducer file is the one above.
App.jsx
import { useReducer, act } from 'react';
import { createRoot } from 'react-dom/client';
import { cartReducer, initialCart } from './cart.js';
function Cart() {
const [cart, dispatch] = useReducer(cartReducer, initialCart);
return (
<div>
<button id="tea" onClick={() => dispatch({ type: 'add', item: 'tea' })}>add tea</button>
<button id="milk" onClick={() => dispatch({ type: 'add', item: 'milk' })}>add milk</button>
<button id="drop" onClick={() => dispatch({ type: 'remove', item: 'milk' })}>remove milk</button>
<p>{cart.items.join(', ') || 'empty'}</p>
</div>
);
}
const root = createRoot(stage);
await act(async () => root.render(<Cart />));
const text = () => stage.querySelector('p').textContent;
const seen = [text()];
await act(async () => stage.querySelector('#tea').click());
seen.push(text());
await act(async () => stage.querySelector('#milk').click());
seen.push(text());
await act(async () => stage.querySelector('#drop').click());
seen.push(text());
console.log(seen);
check('the states, step by step', seen, ['empty', 'tea', 'tea, milk', 'tea']);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node App.jsx
[ 'empty', 'tea', 'tea, milk', 'tea' ] ok the states, step by step
The third argument: init
When the starting state takes work to build, pass the raw input as the second argument and a
function as the third. React calls init(arg) once, at mount, and uses the result as the
state. It does not call it again on later renders, so the work is not repeated.
init.jsx
import { useReducer, act } from 'react';
import { createRoot } from 'react-dom/client';
let initCalls = 0;
function init(count) {
initCalls += 1;
return { items: Array.from({ length: count }, (_, i) => 'item ' + (i + 1)) };
}
function reducer(state, action) {
return action.type === 'more' ? { items: [...state.items, 'item ' + (state.items.length + 1)] } : state;
}
function List({ start }) {
const [state, dispatch] = useReducer(reducer, start, init);
return <button onClick={() => dispatch({ type: 'more' })}>{state.items.join(', ')}</button>;
}
const root = createRoot(stage);
await act(async () => root.render(<List start={2} />));
const first = stage.textContent;
await act(async () => stage.querySelector('button').click());
const second = stage.textContent;
console.log(first, '|', second, initCalls);
check('init built the starting state', first, 'item 1, item 2');
check('dispatch added to it', second, 'item 1, item 2, item 3');
check('init ran once, not on the second render', initCalls, 1);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node init.jsx
item 1, item 2 | item 1, item 2, item 3 1 ok init built the starting state ok dispatch added to it ok init ran once, not on the second render
Why a reducer must be pure
In development, inside StrictMode, React deliberately calls your reducer twice with
the same state and action and keeps one result (lesson 15). If the reducer is pure the two results
are identical and nobody notices. If it pushes into an array or counts something outside itself, the
double call shows up at once. The panel counts the calls, once without StrictMode and
once with it.
twice.jsx
import { StrictMode, useReducer, act } from 'react';
import { createRoot } from 'react-dom/client';
let calls = 0;
function reducer(state, action) {
calls += 1;
return state + action;
}
function Counter() {
const [n, dispatch] = useReducer(reducer, 0);
return <button onClick={() => dispatch(1)}>{n}</button>;
}
async function clickOnce(tree) {
calls = 0;
const root = createRoot(stage);
await act(async () => root.render(tree));
await act(async () => stage.querySelector('button').click());
const result = { calls, shown: stage.textContent };
await act(async () => root.unmount());
return result;
}
const plain = await clickOnce(<Counter />);
const strict = await clickOnce(<StrictMode><Counter /></StrictMode>);
console.log(plain, strict);
check('without StrictMode: one call, one step', plain, { calls: 1, shown: '1' });
check('with StrictMode: two calls, still one step', strict, { calls: 2, shown: '1' });
What node printed (React 19.2.7, development build), when the build ran it
node twice.jsx
{ calls: 1, shown: '1' } { calls: 2, shown: '1' }
ok without StrictMode: one call, one step
ok with StrictMode: two calls, still one step
Reducer or state? One independent value: useState. Several values
that change together, or many different ways to change one value: useReducer. The
reducer's real advantage is that it lives outside the component, so you can test it with plain
function calls, as the first panel did.
- A reducer is
(state, action) => newState: pure, and it never changes its input. - A pure reducer is tested with function calls and
check(), no React. dispatch(action)asks React to run the reducer and render the result.- The third argument
init(arg)runs once, at mount. - In development StrictMode calls the reducer twice per action; only a pure one survives that.
20 Custom hooks
You already use hooks that other people wrote. Writing your own is the same
thing: take logic that uses state or effects, put it in a function, and call it from components.
The only rules are a name that starts with use and that the function calls hooks the way
any component does (lesson 21 is about those rules).
Logic is shared; state is not
A custom hook runs inside the component that calls it, so the state it creates belongs to that
component. Calling the same hook from two components gives two separate pieces of state, exactly
as calling useState twice would. Click one and the other does not move.
toggle.jsx
import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = () => setOn((value) => !value);
return [on, toggle];
}
function Switch({ name }) {
const [on, toggle] = useToggle();
return <button onClick={toggle}>{name}: {on ? 'on' : 'off'}</button>;
}
const root = createRoot(stage);
await act(async () => root.render(<><Switch name="first" /><Switch name="second" /></>));
const texts = () => Array.from(stage.querySelectorAll('button')).map((b) => b.textContent);
const before = texts();
await act(async () => stage.querySelectorAll('button')[0].click());
const after = texts();
console.log(before, after);
check('both start off', before, ['first: off', 'second: off']);
check('one click changed one of them', after, ['first: on', 'second: off']);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node toggle.jsx
[ 'first: off', 'second: off' ] [ 'first: on', 'second: off' ] ok both start off ok one click changed one of them
Returning an array or an object
A hook can return whatever is convenient. An array, like useState, lets the caller
choose the names. An object keeps the names the hook chose, which helps when it returns more than
two things. Neither is better in general: use an array for a pair and an object for a handful.
shapes.jsx
import { useState } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
function useToggle(initial) {
const [value, setValue] = useState(initial);
return [value, () => setValue(!value)];
}
function useCounter(start) {
const [count, setCount] = useState(start);
return { count, increment: () => setCount(count + 1), reset: () => setCount(start) };
}
function Menu() {
const [open, toggleOpen] = useToggle(true);
const [dark, toggleDark] = useToggle(false);
return <p>{open ? 'open' : 'shut'} {dark ? 'dark' : 'light'}</p>;
}
function Score() {
const { count, increment } = useCounter(5);
return <p>{count} {typeof increment}</p>;
}
const menu = renderToStaticMarkup(<Menu />);
const score = renderToStaticMarkup(<Score />);
console.log(menu, score);
check('the array names were the caller\'s own', menu, '<p>open light</p>');
check('the object names were the hook\'s', score, '<p>5 function</p>');
What node printed (React 19.2.7, development build), when the build ran it
node shapes.jsx
<p>open light</p> <p>5 function</p> ok the array names were the caller's own ok the object names were the hook's
A hook that subscribes, and unsubscribes
Most useful hooks wrap an effect: they start something and stop it. This one listens to any
EventTarget and calls a handler. The panel wraps the target's
addEventListener and removeEventListener to count the listeners that are
alive, then dispatches an event while the component is mounted and another after it is gone.
subscribe.jsx
import { useEffect, act } from 'react';
import { createRoot } from 'react-dom/client';
const bus = new window.EventTarget();
let active = 0;
const add = bus.addEventListener.bind(bus);
const remove = bus.removeEventListener.bind(bus);
bus.addEventListener = (type, fn) => {
active += 1;
add(type, fn);
};
bus.removeEventListener = (type, fn) => {
active -= 1;
remove(type, fn);
};
function useEvent(target, type, handler) {
useEffect(() => {
target.addEventListener(type, handler);
return () => target.removeEventListener(type, handler);
}, [target, type, handler]);
}
const heard = [];
function Listener() {
useEvent(bus, 'ping', onPing);
return <p>listening</p>;
}
function onPing() {
heard.push('ping');
}
const root = createRoot(stage);
await act(async () => root.render(<Listener />));
const whileMounted = active;
bus.dispatchEvent(new window.Event('ping'));
await act(async () => root.unmount());
const afterUnmount = active;
bus.dispatchEvent(new window.Event('ping'));
console.log(whileMounted, afterUnmount, heard);
check('one listener while mounted', whileMounted, 1);
check('none after unmount', afterUnmount, 0);
check('the handler ran once, for the event sent while mounted', heard, ['ping']);
What node printed (React 19.2.7, development build), when the build ran it
node subscribe.jsx
1 0 [ 'ping' ] ok one listener while mounted ok none after unmount ok the handler ran once, for the event sent while mounted
A hook with a timer
A timer is the clearest case for cleanup. The hook below starts an interval in an effect and
returns a cleanup that clears it. The panel keeps a set of live timers, waits until the hook has
ticked twice, unmounts, and then waits again to show that the timer does not fire from beyond the
grave. Each wait is inside act, so React may apply the updates the timer makes, and
act applies them when the wait ends.
ticker.jsx
import { useState, useEffect, act } from 'react';
import { createRoot } from 'react-dom/client';
const live = new Set();
let latest = 0;
let fired = 0;
function useTicker(ms) {
const [ticks, setTicks] = useState(0);
useEffect(() => {
const id = setInterval(() => {
fired += 1;
setTicks((n) => n + 1);
}, ms);
live.add(id);
return () => {
clearInterval(id);
live.delete(id);
};
}, [ms]);
return ticks;
}
function Clock() {
latest = useTicker(5);
return <p>ticks {latest}</p>;
}
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const root = createRoot(stage);
await act(async () => root.render(<Clock />));
const liveWhileMounted = live.size;
for (let i = 0; i < 20 && latest < 2; i++) {
await act(async () => {
await wait(20);
});
}
const reached = latest;
await act(async () => root.unmount());
const firedAtUnmount = fired;
await wait(40);
console.log(liveWhileMounted, live.size, reached >= 2, fired === firedAtUnmount);
check('one live timer while mounted', liveWhileMounted, 1);
check('it ticked at least twice', reached >= 2, true);
check('no live timer after unmount', live.size, 0);
check('and the timer stopped firing', fired === firedAtUnmount, true);
What node printed (React 19.2.7, development build), when the build ran it
node ticker.jsx
1 0 true true ok one live timer while mounted ok it ticked at least twice ok no live timer after unmount ok and the timer stopped firing
What a custom hook is not. It is not a way to share state between components: each caller has its own. To share one value, lift it up (lesson 12) or put it in context (lesson 18). A hook shares the code that makes the state, and keeps an effect and its cleanup together in one place.
- A custom hook is a function called
useSomethingthat calls other hooks. - Every caller gets its own state; the hook shares logic, not values.
- Return an array for a pair the caller will name; return an object for several named things.
- Put the effect and its cleanup in the hook so every user gets both.
- Clear timers and remove listeners in the cleanup, and check that you did.
21 The rules of hooks, broken on purpose
The rules of hooks fit on one line: call hooks at the top level of a component or a custom hook, never inside a condition, loop or nested function, and never from ordinary functions. This lesson breaks each part of that rule and reads what React says, so that when you meet the message in your own code you have seen it before.
Why the order matters
A component has no names for its hooks. React keeps a list of slots for each component and hands out the next slot every time you call a hook. On the second render it expects the same calls in the same order. If one call has appeared or vanished, every slot after it is off by one, and React stops rather than give you the wrong state.
A hook inside a condition
The first render below has extra false, so the component calls two hooks. The second
render has it true, so it calls a third one, at the end. React compares the list of hooks it
remembers with the list this render is making, prints a table of the two, and then stops with an
error because there are more hooks than before. The panel checks that the first render worked: the
failure starts with the second.
conditional.jsx
import { useState, useEffect, act } from 'react';
import { createRoot } from 'react-dom/client';
function Toggled({ extra }) {
const [name] = useState('Ada');
useEffect(() => {}, []);
if (extra) {
const [note] = useState('hi');
}
return <p>{name}</p>;
}
const root = createRoot(stage);
await act(async () => root.render(<Toggled extra={false} />));
check('the first render was fine', stage.innerHTML, '<p>Ada</p>');
await act(async () => root.render(<Toggled extra={true} />));
What node printed as it died (React 19.2.7, development build), when the build ran it
node conditional.jsx
ok the first render was fine
React has detected a change in the order of Hooks called by Toggled. This will lead to bugs and errors if not fixed. For more information, read the Rules of Hooks: https://react.dev/link/rules-of-hooks
Previous render Next render
------------------------------------------------------
1. useState useState
2. useEffect useEffect
3. undefined useState
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
/app/node_modules/react-dom/cjs/react-dom-client.development.js:7848
throw Error("Rendered more hooks than during the previous render.");
^
Error: Rendered more hooks than during the previous render.
at updateWorkInProgressHook (/app/node_modules/react-dom/cjs/react-dom-client.development.js:7848:17)
at updateReducer (/app/node_modules/react-dom/cjs/react-dom-client.development.js:7967:18)
at Object.useState (/app/node_modules/react-dom/cjs/react-dom-client.development.js:26511:18)
at process.env.NODE_ENV.exports.useState (/app/node_modules/react/cjs/react.development.js:1263:34)
at Toggled (/app/conditional.jsx:8:20)
at Object.react_stack_bottom_frame (/app/node_modules/react-dom/cjs/react-dom-client.development.js:25904:20)
at renderWithHooks (/app/node_modules/react-dom/cjs/react-dom-client.development.js:7662:22)
at updateFunctionComponent (/app/node_modules/react-dom/cjs/react-dom-client.development.js:10166:19)
at beginWork (/app/node_modules/react-dom/cjs/react-dom-client.development.js:11778:18)
at runWithFiberInDEV (/app/node_modules/react-dom/cjs/react-dom-client.development.js:874:13)
Node.js v26.7.0
The fix is never to skip the hook. Call it every time and put the condition inside or after it, where it only decides what to do with the result.
conditional-fixed.jsx
import { useState, useEffect, act } from 'react';
import { createRoot } from 'react-dom/client';
function Toggled({ extra }) {
const [name] = useState('Ada');
const [note] = useState('hi');
useEffect(() => {}, []);
return <p>{extra ? name + ', ' + note : name}</p>;
}
const root = createRoot(stage);
await act(async () => root.render(<Toggled extra={false} />));
const first = stage.innerHTML;
await act(async () => root.render(<Toggled extra={true} />));
const second = stage.innerHTML;
console.log(first, second);
check('with extra off', first, '<p>Ada</p>');
check('with extra on, the same three hooks, no error', second, '<p>Ada, hi</p>');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node conditional-fixed.jsx
<p>Ada</p> <p>Ada, hi</p> ok with extra off ok with extra on, the same three hooks, no error
A hook after an early return
The same rule is broken more quietly by an early return. Everything after the
return is a hook that may not run. Here the first render calls two hooks and the
second returns after the first, so there are fewer hooks than expected. The message names the likely
cause. (If the early return came before every hook, React has nothing to compare against and
says nothing; the bug is still there.)
early-return.jsx
import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';
function Greeting({ done }) {
const [name] = useState('Ada');
if (done) return <p>finished</p>;
const [greeting] = useState('Hello');
return <p>{greeting}, {name}</p>;
}
const root = createRoot(stage);
await act(async () => root.render(<Greeting done={false} />));
check('the first render was fine', stage.innerHTML, '<p>Hello, Ada</p>');
await act(async () => root.render(<Greeting done={true} />));
What node printed as it died (React 19.2.7, development build), when the build ran it
node early-return.jsx
ok the first render was fine
/app/node_modules/react-dom/cjs/react-dom-client.development.js:7717
throw Error(
^
Error: Rendered fewer hooks than expected. This may be caused by an accidental early return statement.
at finishRenderingHooks (/app/node_modules/react-dom/cjs/react-dom-client.development.js:7717:15)
at renderWithHooks (/app/node_modules/react-dom/cjs/react-dom-client.development.js:7684:7)
at updateFunctionComponent (/app/node_modules/react-dom/cjs/react-dom-client.development.js:10166:19)
at beginWork (/app/node_modules/react-dom/cjs/react-dom-client.development.js:11778:18)
at runWithFiberInDEV (/app/node_modules/react-dom/cjs/react-dom-client.development.js:874:13)
at performUnitOfWork (/app/node_modules/react-dom/cjs/react-dom-client.development.js:17641:22)
at workLoopSync (/app/node_modules/react-dom/cjs/react-dom-client.development.js:17469:41)
at renderRootSync (/app/node_modules/react-dom/cjs/react-dom-client.development.js:17450:11)
at performWorkOnRoot (/app/node_modules/react-dom/cjs/react-dom-client.development.js:16583:35)
at performWorkOnRootViaSchedulerTask (/app/node_modules/react-dom/cjs/react-dom-client.development.js:18957:7)
Node.js v26.7.0
The fix is to move every hook above the first return. A component may return early,
but only after it has called all of its hooks.
early-return-fixed.jsx
import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';
function Greeting({ done }) {
const [name] = useState('Ada');
const [greeting] = useState('Hello');
if (done) return <p>finished</p>;
return <p>{greeting}, {name}</p>;
}
const root = createRoot(stage);
await act(async () => root.render(<Greeting done={false} />));
const first = stage.innerHTML;
await act(async () => root.render(<Greeting done={true} />));
const second = stage.innerHTML;
console.log(first, second);
check('before', first, '<p>Hello, Ada</p>');
check('after, with the early return and no error', second, '<p>finished</p>');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node early-return-fixed.jsx
<p>Hello, Ada</p> <p>finished</p> ok before ok after, with the early return and no error
A hook outside a component
A hook needs a component to belong to. Called from an ordinary function, or at the top of a
module, there is no component being rendered, so React has nowhere to put the state. React first
logs a warning that says exactly that. Then the call fails because the hook reaches for the
renderer that is not there, which is why the final error is a TypeError about
null and not a React message.
outside.mjs
import { useState } from 'react';
function loadName() {
const [name] = useState('Ada');
return name;
}
check('the import itself is fine', typeof useState, 'function');
console.log(loadName());
What node printed as it died (React 19.2.7, development build), when the build ran it
node outside.mjs
ok the import itself is fine
Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app
See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.
/app/node_modules/react/cjs/react.development.js:1263
return resolveDispatcher().useState(initialState);
^
TypeError: Cannot read properties of null (reading 'useState')
at process.env.NODE_ENV.exports.useState (/app/node_modules/react/cjs/react.development.js:1263:33)
at loadName (file:///app/outside.mjs:4:18)
at file:///app/outside.mjs:9:13
at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
at async node:internal/modules/esm/loader:650:26
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)
Node.js v26.7.0
The fix is to call the hook from a component, or from another hook (a function whose name
starts with use, lesson 20). A plain function that needs the value takes it as an
argument instead.
outside-fixed.jsx
import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';
function useName() {
const [name] = useState('Ada');
return name;
}
function shout(name) {
return name.toUpperCase();
}
function Name() {
const name = useName();
return <p>{shout(name)}</p>;
}
const root = createRoot(stage);
await act(async () => root.render(<Name />));
console.log(stage.innerHTML);
check('the hook ran inside a component and the plain function got its value', stage.innerHTML, '<p>ADA</p>');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node outside-fixed.jsx
<p>ADA</p> ok the hook ran inside a component and the plain function got its value
Let a tool catch it
You do not have to run the code to find these. eslint-plugin-react-hooks reads the
source and reports a hook inside a condition, a loop or a plain function, and a dependency array that
does not list everything an effect uses. A Vite project from lesson 2 includes it. It is a package
from the npm registry, so it cannot be installed here; the configuration is shown for your own
machine.
import reactHooks from 'eslint-plugin-react-hooks';
export default [
{
plugins: { 'react-hooks': reactHooks },
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
},
];
needs eslint-plugin-react-hooks from the npm registry, which this offline course never contacts. The panels above show the same mistakes by running them.
- React matches hooks to state by call order, so the order must be the same on every render.
- A hook in a condition: "Rendered more hooks than during the previous render", after a warning about the order.
- A hook after an early return: "Rendered fewer hooks than expected".
- A hook outside a component: "Invalid hook call", then a
TypeErroraboutnull. - Fix by calling hooks unconditionally at the top, and putting the condition inside or after.
eslint-plugin-react-hooksfinds all three before you run the code.