The Rules of Hooks and Why They Exist

Understand the two rules that govern every React hook, why React enforces them, and how to catch violations before they cause subtle bugs.

Hooks look like ordinary function calls, but they are not free-form — useState, useEffect, useContext, and every hook built on top of them only work correctly if you call them in the same order, every single render. Break that order and React silently mismatches state between renders, producing bugs that are notoriously hard to trace back to their cause. The Rules of Hooks exist to prevent exactly that.

Rule 1: Only call hooks at the top level

Never call a hook inside a loop, a conditional, a nested function, or after an early return. Hooks must run unconditionally, in the same order, on every render of the component (or custom hook) that calls them. This feels restrictive at first, because it means you can't write if (condition) { const [x, setX] = useState(0); } — but the restriction is what makes hooks work at all, as the next section explains.

In practice this means: put all your useState, useEffect, useContext, useReducer, useMemo, and useCallback calls at the top of the component body, before any early return statement, and never inside if, for, while, or .map() callbacks. If you need conditional behavior, put the condition inside the hook (for example, inside the useEffect callback, or as an argument to the hook), not around the hook call itself.

The same rule applies to custom hooks — a custom hook is just a regular function that happens to call other hooks, so every hook call inside it must also be unconditional and top-level within that function.

Rule 2: Only call hooks from React functions

Hooks may only be called from two places: the body of a React function component, or the body of another custom hook. They cannot be called from regular JavaScript functions, class components, event handler callbacks defined separately from the render, or outside of React entirely (for example, in a plain utility module that isn't itself a hook).

This rule keeps hooks tied to React's rendering lifecycle. A hook needs to know which component instance it belongs to so it can find or create that instance's slot of state; that only works if the call happens during that component's render (or during another hook's execution that is itself running during that render).

A common violation is calling a hook conditionally inside an event handler, like onClick={() => { if (open) useEffect(...); }} — this is invalid on two counts: it's inside a callback, not a component body, and it's conditional. The fix is always to move the hook call to the top level of the component and let the logic inside the hook handle the conditional part.

Why the rules exist: hook state is tracked by call order

React does not identify each piece of hook state by name — there's no key like 'counter' attached to a useState call. Instead, React keeps a linked list (or array-like structure) of hook 'slots' per component instance, and on every render it walks through that list in order, matching the first useState call to slot 0, the second to slot 1, the third useEffect to slot 2, and so on. This is why the calls must happen in the exact same order every render: if render N calls useState then useEffect, and render N+1 skips the useState call because it was wrapped in an if, React would try to match the useEffect call against the slot that used to belong to useState — reading or writing the wrong state entirely.

This design was a deliberate trade-off. It lets hooks be plain function calls with no extra machinery (no explicit keys, no registration step) while still giving each one persistent state across renders. The cost of that simplicity is the ordering constraint — and the rules of hooks are simply the discipline required to keep that constraint satisfied.

Understanding this mechanism also explains why conditionally *not* calling a hook is different from conditionally deciding what a hook *does*. useState(condition ? 0 : null) is fine — the useState call itself always happens, only the initial value differs. if (condition) { useState(0); } is not fine — the call itself sometimes doesn't happen, which shifts every subsequent hook's slot.

The ESLint plugin: eslint-plugin-react-hooks

Because these violations are easy to introduce accidentally and hard to spot by eye, the React team maintains eslint-plugin-react-hooks, which ships two rules: react-hooks/rules-of-hooks, which statically analyzes your code and errors on any hook call that isn't at the top level of a component or custom hook, and react-hooks/exhaustive-deps, which checks that useEffect, useMemo, and useCallback dependency arrays include everything they reference.

This plugin is included by default in Create React App, Next.js, and Vite's React template, and it should be treated as a hard error, not a warning to dismiss — a rules-of-hooks violation that lints clean by accident today can break the moment someone adds a hook above it or changes a conditional's ordering.

Relying on the linter rather than manual review matters because these bugs frequently don't manifest immediately. A component with a conditional hook call might work fine for months if the condition never actually changes during the component's lifetime in practice, then break in a confusing way the first time it does.

Correct top-level hook placement

Every hook call happens before the if (loading) return — nothing after that line ever calls another hook. The conditional (showDetails) is evaluated inside JSX, not used to decide whether a hook runs.

function UserProfile({ userId, showDetails }: { userId: string; showDetails: boolean }) {
  // All hooks called unconditionally, at the top, in the same order every render
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Conditional logic goes INSIDE the hook, not around it
    if (!userId) return;
    fetchUser(userId).then((u) => {
      setUser(u);
      setLoading(false);
    });
  }, [userId]);

  if (loading) return <Spinner />;

  // Early return AFTER all hooks have run
  return <div>{showDetails && <Details user={user} />}</div>;
}

Code Examples

The useEffect call only happens when isOpen is true on that render, which means the number and order of hook calls changes between renders — exactly what React's slot-matching model cannot tolerate.

if (isOpen) { useEffect(...) } Common mistake: Wrapping a hook call in a condition instead of moving the condition inside the hook's callback or dependency logic.

useEffect is now called on every render, unconditionally — the conditional behavior lives inside the effect callback, and isOpen is listed as a dependency so the effect re-evaluates when it changes. Expected result: Logs 'panel opened' only on renders where isOpen is true, with a stable hook call order.

function Panel({ isOpen }: { isOpen: boolean }) {
  if (isOpen) {
    // ESLint error: React Hook "useEffect" is called conditionally
    useEffect(() => {
      console.log('panel opened');
    }, []);
  }
  return <div>{isOpen ? 'Open' : 'Closed'}</div>;
}
function Panel({ isOpen }: { isOpen: boolean }) {
  useEffect(() => {
    if (!isOpen) return;
    console.log('panel opened');
  }, [isOpen]);

  return <div>{isOpen ? 'Open' : 'Closed'}</div>;
}

How React matches hook calls to state slots

React walks its internal hook list positionally each render — skipping a call on some renders shifts every later hook into the wrong slot.

Render 1:  useState() -> slot0   useEffect() -> slot1   useState() -> slot2
Render 2:  useState() -> slot0   useEffect() -> slot1   useState() -> slot2
           (same order = correct match)

Render with a skipped call (BAD):
Render 1:  useState() -> slot0   useEffect() -> slot1   useState() -> slot2
Render 2:  useState() -> slot0   useState() -> slot1 (WRONG: matched to old useEffect slot)

Important Rules

Only call hooks at the top level of a component or custom hook — never inside loops, conditionals, nested functions, or after an early return.

Only call hooks from React function components or other custom hooks — never from plain functions, class methods, or callbacks defined outside render.

Put conditional logic inside the hook (in the callback body, or as part of what value is passed in), not around the hook call itself.

Always run eslint-plugin-react-hooks with rules-of-hooks set to error, not warn.

A custom hook's name must start with use so the linter (and other developers) can recognize it as a hook and apply these rules to it.

Where this bites teams in practice

The most common real-world trigger is refactoring: a component that returned early for a loading state gets a new useState added below that return, or a hook gets wrapped in an if during a quick bug fix under time pressure. Without the ESLint rule enabled and enforced in CI, this ships and only shows up later as 'state randomly resets' or 'the wrong data shows after a re-render' bug reports that are painful to trace back to a hook ordering issue.

function Form() { if (!isLoggedIn) return null; const [value, setValue] = useState(''); return <input value={value} onChange={(e) => setValue(e.target.value)} />; } function Form() { const [value, setValue] = useState(''); if (!isLoggedIn) return null; return <input value={value} onChange={(e) => setValue(e.target.value)} />; }

The early return happens before useState is called, so on renders where isLoggedIn is false, the hook simply never runs — shifting the slot of every hook after it. Hooks must run before any early return.

items.forEach((item) => { const [checked, setChecked] = useState(false); // hook in a loop });

function ItemRow({ item }: { item: Item }) { const [checked, setChecked] = useState(false); return <li>{item.name}</li>; } // then map: items.map((item) => <ItemRow key={item.id} item={item} />)

A variable number of loop iterations means a variable number of hook calls. Give each list item its own component instance instead, so each one gets its own stable, single useState call.

[multiple-choice] Why must hooks be called in the same order on every render? A. Because JavaScript requires function calls to be deterministic B. Because React matches each hook call to its state by positional order in an internal list, not by name C. Because the browser caches hook results by call index D. It's a stylistic convention with no functional consequence

B. Because React matches each hook call to its state by positional order in an internal list, not by name — React has no name-based lookup for hook state — it relies on hooks being called in an identical sequence every render so it can match each call to the correct persisted slot.

[multiple-choice] Which of the following is a valid place to call a hook? A. Inside a .map() callback that renders a list B. Inside an onClick handler function C. At the top level of a custom hook function D. Inside an if-block guarding optional behavior

C. At the top level of a custom hook function — Custom hooks are valid places to call other hooks, as long as the calls inside them are also at the top level, unconditional, and run every time the custom hook executes.

[true-false] True or false: eslint-plugin-react-hooks can catch every possible Rules of Hooks violation, so manual review is unnecessary. A. True B. False

B. False — The linter's static analysis catches the vast majority of violations in typical code, but highly dynamic patterns (hooks called via computed references, or across complex control flow) can still slip past it — it substantially reduces risk but is not an absolute guarantee.

You have a component that conditionally subscribes to a WebSocket only when a live prop is true, using useEffect wrapped in an if (live) { useEffect(...) }. Rewrite it to satisfy the Rules of Hooks while preserving the same behavior.

useEffect(() => { if (!live) return; const socket = new WebSocket(url); return () => socket.close(); }, [live, url]);

Keep the useEffect call unconditional and put the live check inside the effect's callback (returning early, or conditionally creating/cleaning up the subscription), and add live to the dependency array.