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 sitting next to it snaps back to a stale value it held three interactions ago. Nothing throws. There’s no red overlay, no stack trace, no failed assertion. The state is just wrong, and it’s wrong in a component the user never touched.

The cause is one line: a useState call tucked inside an if. That is the whole bug. It breaks not because React forbids the shape out of fussiness, but because of how React tells your hooks apart in the first place. Once you understand that mechanism, the failure stops feeling like a mystery and starts to look almost like arithmetic.

Every hook you’ve used across this chapter and the last rests on two rules. That includes useState, useRef, useReducer, useEffect, useEffectEvent, useContext, useTransition, and useDeferredValue. The rules are simple to state, easy to break by accident, and fully enforceable by a linter that’s already running in your project. This lesson is the contract underneath all of them.

The order here is deliberate. First comes the one mechanic that makes the rules necessary rather than arbitrary. Then the two rules themselves, which fall straight out of that mechanic. Then the single deliberate exception, use(), the loose end from the last lesson, and why it can break the pattern without breaking anything. Finally, the two ESLint rules that catch all of this before it ever reaches a browser.

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 on demand. The day you hit a hook in a try, a hook in a .map, or a hook after a return, you can reason out the verdict from the mechanic instead of recalling a rule.

The part that surprises people is that 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 little dictionary somewhere mapping "count" to 0, "isOpen" to false, and so on. It does no such thing. React never sees the variable name. count is a label you chose, and it disappears the moment your code is bundled, so React could not read it even if it wanted to.

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 from top to bottom and keeps an internal pointer. The pointer starts at zero, and each hook call advances it by one. The first hook 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.

When React renders the component again, it replays the exact same walk: the pointer goes back to zero and advances by one per call. It expects the first call to land on slot 0 again and the second on slot 1 again, and that expectation is the whole trick. The second time useState runs, React looks at where the pointer is, finds slot 0, and hands back the value the first render stored there. The call remembers nothing itself; the slot does. The call only has to show up in the same place to find its value again.

The diagram below makes that pointer visible. It walks a tiny, well-behaved component through two renders. The component is deliberately plain, just two pieces of state and an effect, because what matters here 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 depends on one fragile assumption: the same calls happen in the same order on every render. Counting only works if you count the same things in the same sequence each time.

So picture what happens when the number or order of calls changes between renders. Suppose the second render skips the first useState and jumps straight to the second. The pointer still starts at zero, because it has no way to know a call went missing, so the call that used to be second now lands on slot 0. It reads back count’s value when it expected draft’s. Every call after it 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 has no way to notice this. The slots are positional: there is no name to compare against, no checksum, nothing that says “wait, this value was supposed to be draft, not count.” React trusts the count. When the count drifts, React confidently returns the wrong value, and your UI breaks somewhere far from the line that actually 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 whole system rests on one promise:

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

Two rules keep that promise, and each is 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.

Stated 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,” and each forbidden location is just a different way the pointer can come up short.

The two shapes you’ll meet most often are real before/after refactors, so each one appears as a pair: the broken version and the fix side by side. Switch between the tabs to compare them.

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 only happens 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 it did last render.

The second shape is the one you’ll actually trip over, not because it is exotic but because it looks like clean code. It is the early return.

You guard a component against missing data: if the data hasn’t loaded, show a spinner and bail out. It reads well, which is the trap. 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 gets broken, precisely because the guard feels like good hygiene.

The remaining two shapes are rarer, so I’ll name each one along 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 can never find 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. This is not an event handler, which is Rule 2’s territory, but a function you define during render and call inline: 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. That one sentence regenerates the whole rule.

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 is allowed to call a hook, and there are exactly two callers: the body of a React function component, or another hook. That’s it. 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 only exists while React is rendering a component. That is the one moment when there is a pointer to advance and slots to claim. Call a hook from a click handler, and ask which render is in progress: none is. The click happened long after the component finished rendering, so there is no pointer, no slot array, nothing to index into, and the hook has nowhere to store its value. The same is true of a standalone utility function you call from anywhere. Outside a render, the machinery the hook depends on is simply not there.

So far this is a rule about humans. The interesting question is how a machine enforces it, since the linter can’t run your code to decide whether a given function is allowed to call hooks. It goes by the name.

React and the lint both use one convention 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 is allowed to call other hooks. Any other name is treated as 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 not decoration or a naming fashion: it is the contract that tells React and the linter “this function plays by the rules of hooks.” The enforcement is exactly that literal.

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 that 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. You call useState once at the top, and the click handler calls setCount, which is just a function and safe to call from anywhere.

For getUser, decide what it actually is. If it genuinely needs render-time React features, it is a hook, so rename it useUser and the lint accepts it. If it doesn’t, it is a plain function that should not be calling a hook at all, 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 linter goes quiet. The warning disappears, but nothing about the function changed: it still runs outside any render, it still has no slot array to write into, 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: functions that call other hooks and run during render. Never use it as a trick to silence a warning on a function that breaks the rules. The moment you rename to dodge a lint error rather than to describe what the function is, you have shipped the bug and hidden 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. For now, the only thing that matters is that such a function is a legitimate place to call hooks, and the use* name is exactly what makes it legitimate.

In the last lesson, reading promises with use(), you met a claim that probably felt like it broke everything you had just learned: use() may be called conditionally, inside an if, after an early return, or in a loop. It is the one React API that is allowed to. I told you then that it was a deliberate exception and promised an explanation later. You now hold the model that makes the explanation land, so here it is.

Every regular hook needs call-order stability because it is tracked by positional slot, which is the whole story above. 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 assigns a slot.

That is the entire reason for the exemption. No slot is being claimed, so 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(). The rule did not get relaxed; the mechanism it guards just does not apply here.

Put the two side by side. They share a syntactic shape but get opposite verdicts, and now you can say why rather than memorize two disconnected facts.

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, which is a real bug. Illegal, exactly as Rule 1 says.

This exception carries one risk: it tempts you to over-generalize 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 the exception tightly bounded:

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

This is exactly one exception, and it generalizes to nothing else: not to useState, not to useMemo, not to any hook you write yourself. The exemption is a property of how use() is tracked, and nothing more. The linter encodes exactly this distinction: it permits a conditional use() and flags conditional everything else. When in doubt, the lint draws the line for you, and it draws it 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”

The good news, after all that mechanism, is that you’ll almost never have to spot a violation by eye.

A linter watches for these violations. It runs every time you save and again in CI , and the part that matters day to day is that it is already in your project. eslint-plugin-react-hooks ships in the default Next.js ESLint config. You inherited it when you scaffolded the app: you did not wire it up, and you won’t here. ESLint does the watching so you don’t have to.

The plugin gives you two rules, and they map cleanly onto the two halves 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 it catches the whole catalogue: a hook in an if, a hook after a return, a hook in a .map, a hook in a handler, a hook in a function that isn’t named use*. The important thing about this rule is how you treat its warnings.

You never disable this rule. A rules-of-hooks violation is a real bug by construction: the slot mechanic is not negotiable, so there is no such thing as a false positive here, and no “I know better than the linter” case. If this rule fires, the code is broken. You fix the structure rather than silence the rule.

react-hooks/exhaustive-deps is the other one, and you have already met it: back in the useEffect lessons it was your correctness oracle for dependency arrays. As a quick reminder rather than a re-teach, it watches the reactive values you read inside a useEffect, useMemo, or useCallback, and flags any you read but forgot to list in the dependency array. When it fires, the fix is almost always the dull one: add the dependency it is pointing at.

This rule earns its trust by also knowing what to leave alone, so you don’t end up fighting it. It won’t demand that you add:

  • A callback wrapped in useEffectEvent. That’s the whole point of useEffectEvent: to read the latest value without becoming a dependency, and the lint knows it.
  • A ref. ref.current is mutable and identity-stable, so listing it would do nothing, and the lint won’t ask.
  • The setter from useState or the dispatch from useReducer. React guarantees those are stable for the life of the component, so they never belong in a dependency array, and the lint knows that too.

If exhaustive-deps isn’t complaining about one of those, it has not missed them; it is correctly leaving them out.

In your config the two rules look like this. Read it for recognition, so you know what you are looking at if you open the file, not so you can hand-author it.

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 two highlighted lines are the whole subject of this section. The Next.js default config already wires both up. You would typically extend its preset rather than write this block yourself, but this is the shape underneath.

That leaves one question worth a senior answer: when would you ever override the lint, and why is reaching for the override a warning sign in itself?

For rules-of-hooks, you already know the answer: never. For exhaustive-deps, there are rare but legitimate overrides. One is a library hook with non-standard dependency semantics the lint can’t model. Another is a genuinely 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 you reached for useEffectEvent, the tool built for exactly this. These cases are real. They are just much rarer than the urge to use them.

The discipline, then, is short: trust the lint by default, disable exhaustive-deps only with a written reason that would survive review, and never touch rules-of-hooks. A disable comment is something your reviewer is allowed to push back on, so treat every one as a claim you have to defend.

Why the rules still hold under the React Compiler

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

There is a reasonable objection here. Your project runs the React Compiler, which auto-memoizes, and that is why you have stopped hand-writing useMemo and useCallback. So the natural thought is: doesn’t the compiler just handle all of this now? Why carry the slot model around in your head?

The honest answer divides cleanly between the two rules.

The compiler reduces how much exhaustive-deps matters, but only for manual memoization. Because you no longer hand-write useMemo and useCallback, there are simply fewer dependency arrays in your code for you to get wrong, and the compiler generates correct dependencies for the memoization it inserts on your behalf. So exhaustive-deps becomes a less frequent visitor. That part of the objection is right.

The compiler does not touch rules-of-hooks, and if anything it makes that rule more important, not less. To analyze your component and decide what to memoize, the compiler reads it assuming the rules hold. It assumes your hooks run unconditionally, in order, every render, the exact stable-call-order property Rule 1 guarantees. Break Rule 1 and you have not just produced a runtime bug; you have given the compiler a false premise, so every conclusion it draws about your component now rests on that false premise. The tool meant to optimize your code cannot reason about code whose hooks don’t run in a fixed order.

So:

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

The misconception to retire is “the compiler means I don’t need to understand the rules of hooks.” The opposite is true: the compiler is a consumer of those rules, and it depends on you holding up your end. (How the compiler actually performs its analysis is a topic for the next chapter. You don’t need its internals to know that it leans on call-order stability.)

The lint is excellent, but it is a static analyzer, and a static analyzer can occasionally be fooled: a hot-reload edge case mid-edit, a dynamically-shaped call it could not trace, or a third-party function that breaks the rules but happens to be named use*, so the lint let it through. In those cases the violation reaches the browser, and 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 that error literally and it is the slot mechanic reported 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 did. The count drifted. It is the same misalignment we started the lesson with, now caught one layer later than the linter would have caught it. React names the component in the error, so open that component and look for the conditional or early-returned hook that changes the count between renders. It is always there.

Here are three quick drills and one hands-on exercise. They all serve the same goal: to confirm you can derive the right answer from the slot model, not just recall a sentence.

First, a diagnostic. Read this component and decide what actually 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.

Next, the exception. Which of these calls are actually allowed to sit inside an if?

Which of these calls may sit inside an if, after an early return, or in a loop without breaking the rules of hooks? Select all that apply.

useState(0)
useEffect(() => { ... })
use(somePromise)
use(SomeContext)
useRef(null)

Now sort the whole rule surface in one pass. Drop each call site into the bucket that matches the verdict.

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 hands-on exercise. This component is a live rules-of-hooks violation: it renders a spinner via an early return that sits above a useState. Restructure it so every render reaches every hook in the same order, while keeping the loading short-circuit 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 exactly where it was; it just 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. The spinner short-circuit is untouched, and only the call 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 is computed on every render even while the spinner shows, but an unused state value costs nothing, and computing it anyway is exactly what keeps the slot count from drifting.

    If you can articulate, for each drill, why the slot count holds or drifts, you have the model that matters. The rules were never the thing to memorize; the counting underneath them 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.