React · lesson 8 of 22
Beginner
5Intermediate
12- 6 Effects and useEffect
- 7 You Might Not Need an Effect
- 8 The Rules of Hooks
- 9 useReducer and useContext
- 10 Custom Hooks
- 11 Measuring Re-renders: memo, useMemo, and useCallback
- 13 Routing with React Router
- 14 Portals with createPortal
- 15 Error Boundaries
- 16 Suspense and lazy
- 17 Reading Promises with use, No Framework Needed
- 18 Retrying After an Error with react-error-boundary
Advanced
5Also part of React
5The 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 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.