Skip to main content
React · lesson 8 of 22
React intermediate Lesson 8 of 22

The Rules of Hooks

Call a hook conditionally and see React's real hook-order error and stack trace, then fix it by keeping every hook call unconditional.

By SkillByExample · Published

react hooks rules-of-hooks

React identifies each hook by the order it was called in, not by name — a linked list built fresh on every render. Breaking that order doesn’t fail quietly; it produces a specific, informative error.

A Conditional Hook Call, and the Exact Error

Call useEffect only when a prop is true, and rendering the same component instance with a different prop value produces a real crash with a hook-order table.

// src/rules-of-hooks/BadHooks.tsx
import { useState, useEffect } from "react";

export function BadHooks({ flag }) {
  const [x] = useState(0);
  if (flag) {
    useEffect(() => {}, []);
  }
  return <p>{x}</p>;
}
// src/rules-of-hooks/BadHooks.test.tsx
import { it } from "vitest";
import { render } from "@testing-library/react";
import { BadHooks } from "./BadHooks.js";

it("changes hook count between renders", () => {
  const { rerender } = render(<BadHooks flag={false} />);
  rerender(<BadHooks flag={true} />);
});
$ npx vitest run rules-of-hooks/BadHooks.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stderr | src/rules-of-hooks/BadHooks.test.tsx > changes hook count between renders
React has detected a change in the order of Hooks called by BadHooks. 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. undefined                  useEffect
   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^


 × src/rules-of-hooks/BadHooks.test.tsx > changes hook count between renders 26ms
   → Rendered more hooks than during the previous render.

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/rules-of-hooks/BadHooks.test.tsx > changes hook count between renders
Error: Rendered more hooks than during the previous render.
 ❯ updateWorkInProgressHook node_modules/react-dom/cjs/react-dom-client.development.js:8224:17
 ❯ updateEffectImpl node_modules/react-dom/cjs/react-dom-client.development.js:9026:18
 ❯ Object.useEffect node_modules/react-dom/cjs/react-dom-client.development.js:29131:9
 ❯ process.env.NODE_ENV.exports.useEffect node_modules/react/cjs/react.development.js:1271:34
 ❯ BadHooks src/rules-of-hooks/BadHooks.tsx:7:5
      5|   const [x] = useState(0);
      6|   if (flag) {
      7|     useEffect(() => {}, []);
       |     ^
      8|   }
      9|   return <p>{x}</p>;
 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:28571:20
 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom-client.development.js:8030:22
 ❯ updateFunctionComponent node_modules/react-dom/cjs/react-dom-client.development.js:10569:19
 ❯ beginWork node_modules/react-dom/cjs/react-dom-client.development.js:12205:18
 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:1048:13

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


 Test Files  1 failed (1)
      Tests  1 failed (1)
   Start at  17:47:09
   Duration  30.02s (environment 72%, setup 27%, transform 1%)

The table says exactly what happened: position 1 was useState both times, fine. Position 2 was nothing (undefined) on the first render, because flag was false and the useEffect call never ran — then useEffect on the second render, because flag became true. React has no concept of “the second hook was skipped”; it just sees a different hook at position 2 than it saw before, and that’s the error — Rendered more hooks than during the previous render — with the exact source line (BadHooks.tsx:7:5) pointing at the conditional call. (The line number is 7, not 6, because the file itself starts with the // src/rules-of-hooks/BadHooks.tsx header comment shown above — the frame’s line numbers are of the real file on disk, comment included.)

The Fix: Keep the Hook Call, Move the Condition Inside

useEffect always runs; the flag check moves inside the callback, where a condition is completely normal.

// src/rules-of-hooks/GoodHooks.tsx
import { useState, useEffect } from "react";

export function GoodHooks({ flag }) {
  const [x] = useState(0);
  useEffect(() => {
    if (!flag) return;
    console.log("flag is on");
  }, [flag]);
  return <p>{x}</p>;
}
// src/rules-of-hooks/GoodHooks.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { GoodHooks } from "./GoodHooks.js";

it("keeps the same hooks on every render, so toggling flag is safe", () => {
  const { rerender } = render(<GoodHooks flag={false} />);
  rerender(<GoodHooks flag={true} />);

  expect(screen.getByText("0")).toBeInTheDocument();
});
$ npx vitest run rules-of-hooks/GoodHooks.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stdout | src/rules-of-hooks/GoodHooks.test.tsx > keeps the same hooks on every render, so toggling flag is safe
flag is on

 ✓ src/rules-of-hooks/GoodHooks.test.tsx > keeps the same hooks on every render, so toggling flag is safe 27ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  17:47:42
   Duration  27.18s (environment 75%, setup 24%, transform 1%)

useEffect is called at position 2 on both renders — the hook itself never moves, only what it does once it’s running changes. "flag is on" only logs on the second render, once flag is true, exactly the conditional behavior the broken version was trying to get by skipping the hook entirely.

Practice

1. Call useState directly inside a button’s onClick handler instead of at the top of the component.

Solution
// src/rules-of-hooks/practice/HookInHandler.tsx
import { useState } from "react";

export function HookInHandler() {
  function handleClick() {
    // Calling a hook outside of render -- not allowed.
    const [value] = useState(0);
    console.log(value);
  }

  return <button onClick={handleClick}>Click</button>;
}
// src/rules-of-hooks/practice/HookInHandler.test.tsx
import { it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HookInHandler } from "./HookInHandler.js";

it("calling useState inside an event handler throws", async () => {
  const user = userEvent.setup();
  render(<HookInHandler />);

  await user.click(screen.getByRole("button", { name: "Click" }));
});
$ npx vitest run rules-of-hooks/practice/HookInHandler.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

 ✓ src/rules-of-hooks/practice/HookInHandler.test.tsx > calling useState inside an event handler throws 139ms
⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯

Vitest caught 1 unhandled error during the test run.
This might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.

⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯
Error: 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.
 ❯ Object.throwInvalidHookError node_modules/react-dom/cjs/react-dom-client.development.js:7965:13
 ❯ process.env.NODE_ENV.exports.useState node_modules/react/cjs/react.development.js:1309:34
 ❯ handleClick src/rules-of-hooks/practice/HookInHandler.tsx:7:21
      5|   function handleClick() {
      6|     // Calling a hook outside of render -- not allowed.
      7|     const [value] = useState(0);
       |                     ^
      8|     console.log(value);
      9|   }
 ❯ executeDispatch node_modules/react-dom/cjs/react-dom-client.development.js:20804:9
 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:1048:13
 ❯ processDispatchQueue node_modules/react-dom/cjs/react-dom-client.development.js:20854:19
 ❯ node_modules/react-dom/cjs/react-dom-client.development.js:21423:9
 ❯ batchedUpdates$1 node_modules/react-dom/cjs/react-dom-client.development.js:3437:40
 ❯ dispatchEventForPluginEventSystem node_modules/react-dom/cjs/react-dom-client.development.js:21008:7
 ❯ dispatchEvent node_modules/react-dom/cjs/react-dom-client.development.js:26142:11

This error originated in "src/rules-of-hooks/practice/HookInHandler.test.tsx" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
The last test to run before this error was "calling useState inside an event handler throws". This means either:
- the error was thrown while Vitest was running this test, or
- the error was thrown after the test completed, and this was the most recent test at that point.
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯


 Test Files  1 passed (1)
      Tests  1 passed (1)
     Errors  1 error
   Start at  17:48:12
   Duration  33.04s (environment 67%, setup 21%, import 10%, transform 1%)

This is a different error from the hook-order one: Invalid hook call, not a position mismatch — dispatchEvent and executeDispatch in the stack show it happened inside the DOM’s own click-dispatch machinery, not inside a render. The click handler runs later, outside of any render React is actively performing, so there’s no hook list to append to at all. The test itself still shows as passed: browsers (and jsdom, matching that behavior) don’t let an exception thrown inside an event listener propagate back to whatever triggered the event — they catch it and report it asynchronously instead, the same way an uncaught error reaches window.onerror. That’s why await user.click(...) returns normally and the it(...) block finishes without throwing, while Vitest separately reports the real error as an unhandled exception for the whole run, exactly as the “This error originated in…” note explains.

2. Fix it by calling useState once at the top of the component and reading its value inside the handler.

Solution
// src/rules-of-hooks/practice/HookAtTop.tsx
import { useState } from "react";

export function HookAtTop() {
  const [value, setValue] = useState(0);

  function handleClick() {
    console.log(value);
    setValue((v) => v + 1);
  }

  return (
    <div>
      <p>Value: {value}</p>
      <button onClick={handleClick}>Click</button>
    </div>
  );
}
// src/rules-of-hooks/practice/HookAtTop.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HookAtTop } from "./HookAtTop.js";

it("calling useState once at the top and reading it in the handler works fine", async () => {
  const user = userEvent.setup();
  render(<HookAtTop />);

  await user.click(screen.getByRole("button", { name: "Click" }));
  await user.click(screen.getByRole("button", { name: "Click" }));

  expect(screen.getByText("Value: 2")).toBeInTheDocument();
});
$ npx vitest run rules-of-hooks/practice/HookAtTop.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stdout | src/rules-of-hooks/practice/HookAtTop.test.tsx > calling useState once at the top and reading it in the handler works fine
0

stdout | src/rules-of-hooks/practice/HookAtTop.test.tsx > calling useState once at the top and reading it in the handler works fine
1

 ✓ src/rules-of-hooks/practice/HookAtTop.test.tsx > calling useState once at the top and reading it in the handler works fine 128ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  17:48:48
   Duration  32.79s (environment 72%, setup 18%, import 9%, transform 1%)

useState runs during render, same as always; the handler is just an ordinary closure reading the value that render produced.

3. Make a second, different conditional hook call — useState itself, not useEffect — and compare the hook-order table it produces.

Solution
// src/rules-of-hooks/practice/ConditionalState.tsx
import { useState } from "react";

export function ConditionalState({ withExtra }) {
  const [a] = useState("a");
  if (withExtra) {
    const [b] = useState("b");
  }
  const [c] = useState("c");

  return <p>{a}{c}</p>;
}
// src/rules-of-hooks/practice/ConditionalState.test.tsx
import { it } from "vitest";
import { render } from "@testing-library/react";
import { ConditionalState } from "./ConditionalState.js";

it("adding a conditional useState changes the hook order between renders", () => {
  const { rerender } = render(<ConditionalState withExtra={false} />);
  rerender(<ConditionalState withExtra={true} />);
});
$ npx vitest run rules-of-hooks/practice/ConditionalState.test.tsx --reporter=verbose

 RUN  v5.0.5 /root/react-app

stderr | src/rules-of-hooks/practice/ConditionalState.test.tsx > adding a conditional useState changes the hook order between renders
React has detected a change in the order of Hooks called by ConditionalState. 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. useState                   useState
3. undefined                  useState
   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^


 × src/rules-of-hooks/practice/ConditionalState.test.tsx > adding a conditional useState changes the hook order between renders 19ms
   → Rendered more hooks than during the previous render.

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/rules-of-hooks/practice/ConditionalState.test.tsx > adding a conditional useState changes the hook order between renders
Error: Rendered more hooks than during the previous render.
 ❯ updateWorkInProgressHook node_modules/react-dom/cjs/react-dom-client.development.js:8224:17
 ❯ updateReducer node_modules/react-dom/cjs/react-dom-client.development.js:8349:18
 ❯ Object.useState node_modules/react-dom/cjs/react-dom-client.development.js:29181:18
 ❯ process.env.NODE_ENV.exports.useState node_modules/react/cjs/react.development.js:1309:34
 ❯ ConditionalState src/rules-of-hooks/practice/ConditionalState.tsx:9:15
      7|     const [b] = useState("b");
      8|   }
      9|   const [c] = useState("c");
       |               ^
     10|
     11|   return <p>{a}{c}</p>;
 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:28571:20
 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom-client.development.js:8030:22
 ❯ updateFunctionComponent node_modules/react-dom/cjs/react-dom-client.development.js:10569:19
 ❯ beginWork node_modules/react-dom/cjs/react-dom-client.development.js:12205:18
 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:1048:13

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


 Test Files  1 failed (1)
      Tests  1 failed (1)
   Start at  17:49:25
   Duration  29.37s (environment 79%, setup 20%, transform 1%)

This time the mismatch shows up at position 3, not 2 — c’s useState call shifted because the conditional b call before it ran this time. The frame points at line 9, const [c] = useState("c"), not at the conditional b call itself: the error fires on the first hook call whose position no longer matches, which is c’s, not b’s. It’s the same underlying problem as the main example: any hook call inside a condition shifts every hook that comes after it, whichever hook it happens to be.

Frequently Asked Questions

What are the Rules of Hooks?
Only call hooks at the top level of a function component or another hook, never inside a condition, loop, or nested function, and never after an early return. React identifies each hook by the position it was called in during a render, so that position has to stay the same on every render for the same component instance.
Why does a conditional hook call break things instead of just skipping that hook?
React doesn't know hooks by name — it tracks them by call order, in a linked list built fresh each render. If a hook that ran at position 2 last render doesn't run this render, every hook after it shifts to the wrong position, and React has no way to tell the difference between "this hook was skipped" and "a different hook moved here."
What does Invalid hook call mean, as opposed to the hook-order error?
Invalid hook call means a hook ran somewhere React wasn't actively rendering a component or another hook at all — commonly inside an event handler, a setTimeout callback, or a plain helper function called from one. The hook-order error is different: it means a hook DID run during a render, just not in the same position as before.
Does the eslint-plugin-react-hooks lint catch this before runtime?
Yes, for the common cases — a hook inside an if, a loop, or after a return. It's a static check on the source code, so it catches it immediately in the editor; the runtime error in this lesson is what happens if that lint is disabled, ignored, or the violation is written in a way the lint can't see.