Skip to content
Chapter 25Lesson 8

Rules of hooks and the lint that enforces them

The two rules every React hook must obey, and the eslint-plugin-react-hooks linter that enforces them.

A component renders fine. Then a user opens a side panel, and the counter beside it snaps back to a value it held three interactions ago. Nothing throws: no red overlay, no stack trace, no failed assertion. The state is just wrong, in a component the user never touched.

The cause is one line: a useState call tucked inside an if. It breaks because of how React tells your hooks apart from one render to the next. Once you understand that mechanism, the failure stops being a mystery.

Every hook you’ve used rests on two rules, from useState and useRef to useEffect, useContext, and useTransition. The rules are short, easy to break by accident, and fully caught by a linter already running in your project.

The goal is not to memorize a list of don’ts. It is to hold one model in your head and re-derive every rule from it. When you hit a hook inside a try, a .map, or after an early return, you reason out the verdict from the mechanism instead of recalling a rule.

How React matches each hook to its stored value

Section titled “How React matches each hook to its stored value”

React has no idea what your hooks are called.

When you write const [count, setCount] = useState(0), you might imagine React filing that value away under the name "count", a dictionary somewhere mapping "count" to 0, "isOpen" to false, and so on. It does no such thing. count is a label you chose, and it disappears once your code is bundled, so React never sees it.

So how does the second render know which stored value belongs to which useState call? It counts.

Every time React renders your component, it walks the function body top to bottom and keeps an internal pointer. The pointer starts at zero, and each hook call advances it by one: the first call lands on slot 0, the second on slot 1, the third on slot 2. A hook’s entire identity is its position in the call sequence, not its name and not its variable, but its place in line.

On the next render, React replays the exact same walk: the pointer resets to zero and advances one per call, expecting the first call to land on slot 0 again and the second on slot 1 again. That expectation is the whole trick. The second time useState runs, React reads the pointer, finds slot 0, and hands back the value the first render stored there. The call remembers nothing; the slot does. A call only has to show up in the same place to find its value again.

The diagram below makes the pointer visible. It walks a small, well-behaved component through two renders, two pieces of state and an effect, so what you watch is the call structure, not what each hook does. Scrub through it and watch the pointer advance.

const Counter = () => { const [count, setCount] = useState(0); const [draft, setDraft] = useState(''); useEffect(connectToServer); // ... };
Render 1 pointer at 0
Slot 0 empty
Slot 1 empty
Slot 2 empty
pointer
Render 1 begins. The slot array is empty and the pointer sits at 0. React is about to walk the function top to bottom.
const Counter = () => { const [count, setCount] = useState(0); const [draft, setDraft] = useState(''); useEffect(connectToServer); // ... };
Render 1 pointer at 1
Slot 0 useState count 0
Slot 1 empty
Slot 2 empty
pointer
The first hook call — useState(0) for count — claims slot 0. React stores 0 there and the pointer advances to 1.
const Counter = () => { const [count, setCount] = useState(0); const [draft, setDraft] = useState(''); useEffect(connectToServer); // ... };
Render 1 pointer at 2
Slot 0 useState count 0
Slot 1 useState draft ''
Slot 2 empty
pointer
The second call — useState('') for draft — claims slot 1. The pointer advances to 2.
const Counter = () => { const [count, setCount] = useState(0); const [draft, setDraft] = useState(''); useEffect(connectToServer); // ... };
Render 1 pointer at end
Slot 0 useState count 0
Slot 1 useState draft ''
Slot 2 useEffect connectToServer
pointer · done
The useEffect call claims slot 2. The pointer advances past the end and the walk is done. React recorded three hooks, in this exact order — and that order is all it knows.
const Counter = () => { const [count, setCount] = useState(0); const [draft, setDraft] = useState(''); useEffect(connectToServer); // ... };
Render 2 same calls, same order
Slot 0 useState count 1
Slot 1 useState draft ''
Slot 2 useEffect connectToServer
pointer
Render 2 runs the same walk: pointer back to 0, the same three calls in the same order. Each call lands on the slot it claimed last time and reads back its own stored value. count has advanced to 1 — the slot remembered.

This is where the rules come from. The whole scheme rests on one fragile assumption: the same calls happen in the same order on every render.

Picture what happens when they don’t. Suppose the second render skips the first useState and jumps straight to the second. The pointer still starts at zero, because nothing tells it a call went missing, so the call that used to be second now lands on slot 0 and reads back count’s value when it expected draft’s. Every later call shifts by one too, each reading the slot that belongs to its neighbor. useState hands you another hook’s state, and an effect fires with the wrong dependencies.

React cannot notice this. The slots are positional, so there is no name to compare against, no checksum, nothing saying “wait, this was supposed to be draft, not count.” React trusts the count, and when the count drifts it confidently returns the wrong value, breaking your UI somewhere far from the line that caused it.

This is why the rule cannot be a soft suggestion. A human reviewer cannot reliably eyeball whether every render reaches every hook in the same order, because the drift hides inside an innocent-looking if. So the requirement is mechanical and the enforcement is automated.

The rules are not React being fussy. They are the only way positional slots can work.

Two rules keep the order stable, each a different way of saying “don’t let the count drift.” We’ll take them one at a time.

Rule 1: call hooks at the top level, every render

Section titled “Rule 1: call hooks at the top level, every render”

Call your hooks at the top level of the function body. Never inside a conditional, never inside a loop, never inside a nested function, never after an early return. Every render must reach every hook call, in the same order, top to bottom.

On its own that sounds like a style preference. Tied to the slot pointer, it is the literal meaning of “don’t make the count drift”: each forbidden location is just a different way the pointer can come up short.

The two shapes you’ll meet most often come as before/after pairs. Switch between the tabs to compare the broken version and the fix.

const Panel = ({ showCount }: { showCount: boolean }) => {
if (showCount) {
const [count, setCount] = useState(0);
return <Counter value={count} onInc={() => setCount(count + 1)} />;
}
return <Placeholder />;
};

Slot drift. The useState call runs only when showCount is true. On the renders where it is false the call never runs, so the next hook below it slides up into slot 0. Flip showCount back and the call reappears, pushing every slot down again. The pointer is now counting a different set of hooks than last render.

The second shape is the one you’ll actually trip over, because it looks like clean code: the early return. You guard a component against missing data, showing a spinner until it loads. An early return makes every hook beneath it conditional on the data being present. Loading renders run fewer hooks than loaded renders, so the count drifts the instant the data arrives.

const Profile = ({ userId }: { userId: string }) => {
const user = useUser(userId);
if (!user) return <Spinner />;
const [isEditing, setIsEditing] = useState(false);
return <ProfileCard user={user} editing={isEditing} onEdit={setIsEditing} />;
};

Conditional on the data. While user is null, the render stops at the spinner and the useState below never runs. The moment user loads, the early return is skipped and useState fires for the first time: a hook appears that wasn’t there last render. React expected the same count and gets one more. This is the most common way the rule breaks, precisely because the guard feels like good hygiene.

The remaining two shapes are rarer, so I’ll name each one with its fix. The cause and the cure are the same in both.

A hook in a loop. items.map(() => useEffect(...)) calls the effect once per item, so the call count changes the moment the array grows or shrinks, and the pointer never finds a stable position. The fix is not to remove the effect but to give each item its own component: render <Row key={item.id} item={item} /> and let each Row call its one effect at its top level. Each row then owns one hook in a fixed position.

A hook in a nested function. Not an event handler, which is Rule 2’s territory, but a function you define and call during render: the factory you pass to useMemo, or a small helper declared inside the body. A hook called from there is not on the top-level path either, so it runs only when that inner function runs. Pull the hook out to the body, where the render walk reaches it directly.

All four shapes share a single fix:

The fix is almost never to remove the hook. It is to make the call unconditional and push the condition onto the value, or into a child component.

Hold that idea and you never have to memorize the four shapes. A conditional decides what to do with a hook’s result; it must never decide whether the hook runs.

Rule 2: call hooks only from components or other hooks

Section titled “Rule 2: call hooks only from components or other hooks”

The second rule is about who may call a hook, and there are exactly two callers: the body of a React function component, or another hook. Not an event handler, not a plain utility function, not a class method, not a top-level module statement.

Trace it back to the pointer and the reason is clear. The slot array exists only while React is rendering a component, the one moment when there is a pointer to advance and slots to claim. Call a hook from a click handler and no render is in progress: the click happened long after the component finished rendering, so the hook has no pointer, no slot array, nowhere to store its value. The same goes for a utility function you call from anywhere. Outside a render, the machinery the hook depends on is simply not there.

That is a rule for humans. A linter enforces it without running your code, so it goes by the name.

React and the lint treat the name as the entire signal. A function whose name starts with use followed by a capital letter, such as useUser, useToggle, or useCartTotal, is treated as a hook, and a hook may call other hooks. Any other name is an ordinary function, and ordinary functions may not call hooks. There is no analysis of what the function does; there is only the prefix. That makes the prefix load-bearing: it is the contract telling React and the linter “this function plays by the rules of hooks.”

function handleClick() {
const [count, setCount] = useState(0);
}
function getUser(id: string) {
return useContext(UserContext);
}

handleClick runs on a click, long after render. getUser is a plain function the lint refuses by name alone. Both fixes follow the rule rather than work around it.

For handleClick, the hook belongs in the component body and the handler reads or sets the result: call useState once at the top, and let the click handler call setCount, which is safe to call from anywhere.

For getUser, decide what it actually is. If it needs render-time React features, it is a hook, so rename it useUser and the lint accepts it. If it does not, it is a plain function that should not be calling a hook, so remove the call.

That useUser rename hides the trap that separates understanding this rule from copying it blindly:

The naming convention is a contract the lint trusts, not one it verifies.

Rename a rule-breaking utility to useThing and the warning goes quiet, but nothing about the function changed: it still runs outside any render, and it still breaks. The prefix does not make a function obey the rules; it only promises that it does. You have silenced the warning, not fixed the bug. So reserve the use prefix for functions that genuinely are hooks, ones that call other hooks and run during render, and never as a trick to quiet a warning. Rename to dodge a lint error rather than to describe the function, and you have shipped the bug while hiding the one signal that would have caught it.

Writing your own use* functions to share stateful logic across components is the real reason this naming contract exists, and it is the subject of the next chapter.

The previous lesson, reading promises with use(), made a claim that seems to break the rules: use() may be called conditionally, inside an if, after an early return, or in a loop. It is the one React API allowed to. Here is why.

Every regular hook needs call-order stability because it is tracked by positional slot. use() is not tracked that way at all. When you write use(promise), React identifies that value by its referential identity , not by which numbered call it was. When you write use(context), React resolves it by the component’s position in the tree, walking up to find the nearest provider. Neither path claims a slot.

That is the whole reason for the exemption. With no slot to claim, there is no slot to misalign when the call moves around. The count, the fragile thing the other rules exist to protect, is simply not in play for use().

Put the two side by side: same syntactic shape, opposite verdicts.

const Widget = ({ ready }: { ready: boolean }) => {
if (!ready) return null;
const [value, setValue] = useState(0);
return <Display value={value} />;
};

Slot claimed. useState claims a slot. Skipping it on the not-ready renders and claiming it on the ready ones makes the count drift, a real bug. Illegal, exactly as Rule 1 says.

The one risk in this exception is over-generalizing it. A reader who walks away thinking “so conditional hooks are sometimes fine” is one careless edit away from wrapping a useState in an if and shipping a slot bug. So keep it tightly bounded:

use() is exempt because it has no slot to lose, not because the rules got softer.

The exemption generalizes to nothing else: not to useState, not to useMemo, not to any hook you write. It is a property of how use() is tracked, and nothing more. The linter encodes exactly this distinction, permitting a conditional use() and flagging conditional everything else. When in doubt, the lint draws the line in the same place this reasoning does.

The lint that enforces this: eslint-plugin-react-hooks

Section titled “The lint that enforces this: eslint-plugin-react-hooks”

After all that mechanism, the good news is that you’ll almost never have to spot a violation by eye. A linter does it for you, on every save and again in CI . And it’s already in your project: eslint-plugin-react-hooks ships in the default Next.js ESLint config, so you inherited it when you scaffolded the app without wiring up anything.

The plugin gives you two rules, one per half of this lesson.

react-hooks/rules-of-hooks enforces Rules 1 and 2: top-level calls only, use*-named callers only. It is purely structural and catches the whole catalogue, a hook in an if, after a return, in a .map, in a handler, or in a function not named use*.

You never disable this rule. A violation is a real bug by construction: the slot mechanic is not negotiable, so there are no false positives and no “I know better than the linter” case. If the rule fires, the code is broken, and you fix the structure rather than silence the rule.

react-hooks/exhaustive-deps you have already met: in the useEffect lessons it was your correctness oracle for dependency arrays. It watches the reactive values you read inside a useEffect, useMemo, or useCallback and flags any you forgot to list. The fix is almost always the dull one: add the dependency it points at.

It also knows what to leave alone, so you don’t end up fighting it. It won’t ask you to add a callback wrapped in useEffectEvent (built to read the latest value without becoming a dependency), a ref (ref.current is mutable and identity-stable, so listing it does nothing), or the setter from useState or dispatch from useReducer (React keeps these stable for the component’s life). If it isn’t complaining about one of these, it hasn’t missed it; it is correctly leaving it out.

In your config the two rules look like this. Read it for recognition, so you know what you’re looking at if you open the file.

eslint.config.mjs
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',
},
},
];

The Next.js default config already wires both up. You’d extend its preset rather than hand-author this block, but this is the shape underneath.

So when would you ever override the lint? For rules-of-hooks, never. For exhaustive-deps, the legitimate cases are rare: a library hook with dependency semantics the lint can’t model, or a one-time effect where adding the flagged dependency would cause unwanted re-runs and useEffectEvent somehow doesn’t fit. In 2026 that second case is nearly always a sign you reached for the disable before reaching for useEffectEvent, the tool built for exactly this.

Why the rules still hold under the React Compiler

Section titled “Why the rules still hold under the React Compiler”

Your project runs the React Compiler, which auto-memoizes, so you no longer hand-write useMemo and useCallback. Doesn’t that handle all of this for you? The answer splits across the two rules.

The compiler reduces how much exhaustive-deps matters. With no hand-written dependency arrays in your code, there are fewer to get wrong, and the compiler generates correct dependencies for the memoization it inserts. So that lint fires less often.

The compiler does not touch rules-of-hooks, and it makes that rule more important. To decide what to memoize, the compiler reads your component assuming the rules hold: that every hook runs unconditionally, in order, every render, the stable call order Rule 1 guarantees. Break Rule 1 and you hand the compiler a false premise, so every conclusion it draws about your component is unsound. The tool meant to optimize your code cannot reason about hooks that don’t run in a fixed order.

Keep both rules on. The compiler makes one a less frequent worry. It makes the other non-negotiable.

So retire the idea that the compiler means you no longer need to understand the rules of hooks. The compiler is a consumer of those rules, and it depends on you holding up your end.

The lint is a static analyzer, and a static analyzer can occasionally be fooled: a hot-reload edge case mid-edit, a dynamically-shaped call it cannot trace, or a third-party function that breaks the rules but happens to be named use*. Then the violation reaches the browser, where React catches it at render time with an error worth learning to read on sight.

const Profile = ({ user }: { user: User | null }) => {
if (!user) return <Spinner />;
const [isEditing, setIsEditing] = useState(false);
// ^ React: "Rendered more hooks than during the previous render."
return <ProfileCard user={user} editing={isEditing} />;
};

Read the error literally and it is the slot mechanic in plain words. “Rendered more hooks than during the previous render” (or its twin, “Rendered fewer hooks than expected”) means exactly one thing: this render reached a different number of hook calls than the last one. The count drifted, the same misalignment we opened the lesson with, now caught a layer later than the linter would. React names the component, so open it and look for the conditional or early-returned hook that changes the count between renders. It is always there.

Two drills and one exercise. Each asks you to derive the verdict from the slot model, not recall a rule.

First, a diagnostic: read this component and decide what goes wrong.

This component throws the first time invoice finishes loading and the card renders. What is the underlying cause?

const Invoice = ({ invoiceId }: { invoiceId: string }) => {
const invoice = useInvoice(invoiceId);
if (!invoice) return <Spinner />;
const [showLines, setShowLines] = useState(true);
return (
<InvoiceCard
invoice={invoice}
showLines={showLines}
onToggle={setShowLines}
/>
);
};
The early return sits above useState, so the spinner renders run one hook and the card renders run two — the call count React expects to repeat is no longer the same from one render to the next.
setShowLines closes over the invoice from the render that defined it, so toggling the lines reads a stale copy of the invoice data.
useInvoice never lists invoiceId in its dependency array, so it skips re-fetching and hands the card a value it can’t render.
Each toggle schedules another render, and React aborts the component to stop a runaway re-render loop.

Now sort every call site into the bucket that matches its verdict. Watch for the exception: use() is the one call that may sit inside an if, after an early return, or in a loop.

Sort each call site by whether the rules of hooks allow it. Drag each item into the bucket it belongs to, then press Check.

Allowed here The rules of hooks permit this call site
Rules-of-hooks violation The call would drift the slot count
A useState call at the top of a component body
use(theme) after an early return null
use(dataPromise) inside an if branch
A useEffect called inside a use*-named custom hook
A useState call inside an if block
A useEffect called inside items.map(...)
A useState call inside a handleClick event handler
A useContext call inside a function named getTheme()

Finally, the fix. This component’s early return sits above a useState, a live rules-of-hooks violation. Restructure it so every render reaches every hook in the same order, keeping the loading spinner intact.

This component calls a hook after an early return, so the loading render and the loaded render run a different number of hooks — it crashes the moment you click Load. Move the hooks so every render reaches them in the same order, keeping the loading short-circuit (the spinner) in place. The tests click Load and then Like; make all three pass.

Preview
    Reference solution

    Hoist both useState calls above the if (!user) return …. The early return stays put; it now runs after every hook has claimed its slot, so the loading render and the loaded render walk the same two hooks in the same order. Only the placement changed.

    export function App() {
    const [user, setUser] = useState<{ name: string } | null>(null);
    const [likes, setLikes] = useState(0);
    if (!user) {
    return (
    <div className="space-y-3 p-4">
    <p role="status">Loading…</p>
    <button onClick={() => setUser({ name: 'Ada' })}>Load</button>
    </div>
    );
    }
    return (
    <div className="space-y-3 p-4">
    <p className="font-medium">{user.name}</p>
    <p data-testid="likes" className="text-3xl tabular-nums">{likes}</p>
    <button onClick={() => setLikes(likes + 1)}>Like</button>
    </div>
    );
    }

    The discipline in one line: hooks first, returns second. likes now runs on every render, even while the spinner shows, but an unused state value costs nothing, and running it anyway is what keeps the slot count from drifting.

    If you can say why the slot count holds or drifts in each drill, you have the model. The rule was never the thing to memorize; the counting under it was.

    The React docs are the canonical reference for both the rules themselves and the lint that enforces them. The first is worth a slow read. The second is the page to keep bookmarked for the day a react-hooks warning catches you off guard.