The Rules of Hooks, Explained
What you'll learn
- Why "only call hooks at the top level" follows from the linked list, not from arbitrary law
- A render-by-render breakdown of a conditional hook corrupting state
- Why early returns are the same bug in disguise
- Which loops are mechanically safe and which are broken
- Why hooks only work inside React functions, and what custom hooks really do to your list
"Don't call hooks inside conditions, loops, or nested functions" is usually handed down as a rule to memorize and a linter to obey. But you already own the mechanism: an ordered list of cells and a cursor that matches calls by position. So instead of memorizing, let's break the rule on purpose and watch exactly what goes wrong. After this chapter the rule will feel like gravity, not bureaucracy.
The setup: a component we're about to break
import { useEffect, useRef, useState } from 'react';
function Profile({ showBio }) {
const [name, setName] = useState('Ada'); // hook 0, always
if (showBio) {
const [bio, setBio] = useState('...'); // hook 1, sometimes!
}
const inputRef = useRef(null); // hook 1 or 2, depends!
useEffect(() => { // hook 2 or 3, depends!
document.title = name;
}, [name]);
return <input ref={inputRef} defaultValue={name} />;
}
What happens, render 1, with showBio = true:
- Cursor 0:
useState('Ada')→ create cell 0: a state cell. - The
ifruns → cursor 1:useState('...')→ create cell 1: a state cell. - Cursor 2:
useRef(null)→ create cell 2: a ref cell. - Cursor 3:
useEffect(...)→ create cell 3: an effect cell. - List:
[nameState, bioState, ref, effect]. Everything works.
What happens, render 2, after the parent flips showBio to false:
- Cursor 0:
useState→ cell 0 is a state cell. Fine. - The
ifis skipped. No call happens here. - Cursor 1: your code calls
useRef(null), but cell 1 is the bio state cell. React hands your "ref" a state record. - Cursor 2: your code calls
useEffect(...), but cell 2 is a ref cell. React looks for deps and a cleanup on a{ current }box. - Every hook after the conditional is shifted by one position. Values land in the wrong variables; the effect machinery reads nonsense.
Here is the desync as a table:
| Cursor | Render 1 (showBio = true) | Render 2 (showBio = false) |
|---|---|---|
| 0 | useState → state cell | useState → state cell ✓ |
| 1 | useState → state cell | useRef → reads state cell ✗ |
| 2 | useRef → ref cell | useEffect → reads ref cell ✗ |
| 3 | useEffect → effect cell | , (never called) |
In practice, modern React saves you from the worst of it: when a render ends with a different number of hooks than the previous render, React throws an error about rendered hooks not matching. Treat that error as a smoke alarm. The fire is always the same: positional identity broke.
Early returns are the same bug in disguise
import { useEffect, useState } from 'react';
function Profile({ user }) {
const [clicks, setClicks] = useState(0);
if (!user) {
return <p>Please log in</p>; // skips the hook below on some renders!
}
useEffect(() => {
document.title = user.name;
}, [user]);
return <button onClick={() => setClicks(c => c + 1)}>{clicks}</button>;
}
What happens:
- Render with
user = null: one hook call, then an early exit. List:[clicksState]. - Render after login: two hook calls. React expected the previous list shape, one cell, and now a second call appears. Same desync, same class of error.
The fix is to keep the calls unconditional and move the condition inside:
import { useEffect, useState } from 'react';
function Profile({ user }) {
const [clicks, setClicks] = useState(0);
useEffect(() => {
if (user) {
document.title = user.name;
}
}, [user]);
if (!user) {
return <p>Please log in</p>; // fine: all hooks already ran above
}
return <button onClick={() => setClicks(c => c + 1)}>{clicks}</button>;
}
What happens: both hooks run on every render in the same order; the conditional logic lives inside the effect and after the hooks. An early return placed after all hook calls is perfectly legal, it skips no cells.
Conditions belong inside hooks, never around them.
Loops: fixed counts are stable, variable counts are broken
Mechanically, a hook inside a loop works if, and only if, the loop runs the same number of times on every render:
import { useState } from 'react';
function ThreeLights() {
const lights = [];
for (let i = 0; i < 3; i++) {
// eslint-disable-next-line react-hooks/rules-of-hooks
lights.push(useState(false));
}
return (
<div>
{lights.map(([on, setOn], i) => (
<button key={i} onClick={() => setOn(o => !o)}>
Light {i}: {on ? 'on' : 'off'}
</button>
))}
</div>
);
}
What happens:
- Every render makes exactly three calls → always three cells → the cursor walk is stable. It genuinely works.
- But change the loop bound to something dynamic,
i < items.length, and the dayitemsgrows or shrinks, the list desyncs exactly like theifexample. - Because humans are bad at guaranteeing loop stability forever, the rule simply says: don't. If you need N pieces of state, render N child components, each gets its own node and its own list.
The rule exists not because loops are always wrong, but because "same count every render" is a promise you can't maintain.
Rule 2: only call hooks in React functions
The whole mechanism needs a currently rendering node to attach cells to. A hook called from a regular helper, or an event handler, or a setTimeout callback, runs when no component is rendering:
import { useState } from 'react';
function makeCounterState() {
const [count, setCount] = useState(0); // invalid hook call
return [count, setCount];
}
What happens: React throws "Invalid hook call". There is no current node, so there is nowhere to put the cell.
Jargon: "invalid hook call". The error React throws when a hook runs outside a component render. The same error appears when two duplicate copies of React are bundled: the hook registered with copy A runs while copy B is rendering, and neither sees a current node.
Hooks work in exactly two places: the body of a function component, and the body of a custom hook, which, as you're about to see, is the same place by the time it runs.
Custom hooks just extend your list
import { useEffect, useState } from 'react';
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function onResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return width;
}
function App() {
const [theme, setTheme] = useState('light');
const width = useWindowWidth();
return <p className={theme}>Width: {width}</p>;
}
What happens:
Apprenders. Cursor 0:useState('light'), App's own cell.- Then
useWindowWidth()is called, and its body executes inline during App's render. Cursor 1: itsuseState. Cursor 2: itsuseEffect. - The flattened list is
[themeState, widthState, resizeEffect]. - There is no namespacing, no scoping, no magic boundary. A custom hook is a plain function call whose hooks append to your list, exactly as if you'd written them inline.
Jargon: "custom hook". A plain function whose name starts with
usethat calls other hooks. At render time its calls interleave into the caller's list as if written inline.
This is why custom hooks must follow the same rules: they literally are your hooks. And it's why they can share logic but never state, each caller gets its own cells.
The ESLint plugin exists because the rules are mechanical
"Same calls, same order, every render" is a statement a program can check statically, without running your app. The react-hooks/rules-of-hooks lint rule flags conditional, looped, and nested calls; exhaustive-deps uses the same call-position knowledge to audit effect dependencies. The rules are mechanical, so the enforcement is mechanical, wire the plugin in and let the machine remember this chapter for you.
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception: The rules of hooks are arbitrary style laws. Reality: they're the direct consequence of position-based cells, break the order and the cursor reads garbage.
- Misconception: Any early return breaks hooks. Reality: only returns that skip hook calls. A return placed after all hooks is fine.
- Misconception: A conditional hook that "works in testing" is safe. Reality: the first render where the condition flips will desync or throw, every time, by mechanism.
- Misconception: Custom hooks get their own private hook storage. Reality: their calls flatten into the caller's list; that's why they share logic but never state.
- Misconception: The ESLint plugin is optional hygiene. Reality: it statically verifies a rule you cannot reliably eyeball, treat its errors as real bugs.
- Misconception: "Invalid hook call" means your syntax is wrong. Reality: it means no component was rendering when the hook ran, wrong place, or duplicate React copies in the bundle.
Why it works this way
- Position-as-identity is what makes hook syntax so light. No keys or names at call sites, and the price is the call-order rule.
- Guard-rail errors exist because silent desync is worse. Without them, values would shuffle between variables and the crash would surface far from the cause.
- Flattening custom hooks keeps composition free. Logic moves between a component and a hook with zero storage changes, copy, paste, rename.
- Mechanical rules invite mechanical enforcement. The linter turns a class of heisenbugs into red squiggles at write time.
Try it yourself
- Add the conditional-
useStateexample to a real app with a toggle prop. Flip the toggle and read the error React throws, notice it describes this chapter's mechanism. - Fix it by always calling the hook and using its value conditionally. Confirm both renders stay stable.
- Run the early-return example with
userstarting asnull, then logging in. Watch the failure, then apply the hoisted-hooks fix. - Call
useWindowWidth()twice inside one component. Predict the flattened list, four cells, then confirm both widths update independently on resize.
Recap
- Hooks are matched to cells by call position; anything that changes which calls run,
if, early return, variable loop, desynchronizes the list. - A conditional hook shifts every later hook by one cell: values and effects land on the wrong records.
- Early returns are conditional hooks in disguise; put conditions inside hooks instead.
- Fixed-count loops are mechanically stable but still banned; variable loops are genuinely broken. Use child components for N pieces of state.
- A hook outside rendering has no node to attach to → "invalid hook call" (also caused by duplicate React copies).
- Custom hooks inline into your list, same cells, same rules, and ESLint enforces all of it mechanically.