Emmanuel Akinfulubi

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.

Default, provider, nested provider

ran at build time · in happy-dom 20.11.6 · React dev · runs here too

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.

A change reaches the readers only

ran at build time · in happy-dom 20.11.6 · React dev · runs here too

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.

A new object is a new value

ran at build time · in happy-dom 20.11.6 · React dev · runs here too

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) after an early return

ran at build time · in happy-dom 20.11.6 · React dev · runs here too

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>; .Provider still 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; useContext may 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.

Testing the reducer without React

ran at build time · server (no DOM) · React dev

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.

dispatch from the buttons

ran at build time · in happy-dom 20.11.6 · React dev

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.

A lazy initial state

ran at build time · in happy-dom 20.11.6 · React dev

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.

The reducer is called twice in StrictMode

ran at build time · in happy-dom 20.11.6 · React dev

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.

One hook, two independent copies

ran at build time · in happy-dom 20.11.6 · React dev

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.

An array and an object

ran at build time · server (no DOM) · React dev

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.

A hook that cleans up after itself

ran at build time · in happy-dom 20.11.6 · React dev

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.

A timer that is cleared

ran at build time · in happy-dom 20.11.6 · React dev

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 useSomething that 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.

More hooks than the render before

dies with Error · in happy-dom 20.11.6 · React dev

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.

The same hooks every render

ran at build time · in happy-dom 20.11.6 · React dev

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.)

Fewer hooks than expected

dies with Error · in happy-dom 20.11.6 · React dev

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.

Hooks first, then the early return

ran at build time · in happy-dom 20.11.6 · React dev

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.

A hook called from a plain function

dies with TypeError · server (no DOM) · React dev

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.

The hook in a component, the plain function as a helper

ran at build time · in happy-dom 20.11.6 · React dev

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.

eslint.config.js on your own machine

shown, not run
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 TypeError about null.
  • Fix by calling hooks unconditionally at the top, and putting the condition inside or after.
  • eslint-plugin-react-hooks finds all three before you run the code.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.