#Rules of hooks
Hooks come with two rules, and every hook bug you'll see in an interview breaks one of them:
- Only call hooks at the top level of a component or custom hook — never inside
if, loops, nested functions, or after an earlyreturn. - Only call hooks from React functions — function components and custom hooks. Not from plain helper functions, event handlers or class components.
The reason is simple once you see it: React doesn't know your hooks by name, only by position. When Profile calls useState("Rohit") and then useState(0), React stores "slot 1: "Rohit", slot 2: 0" against that component. On the next render it hands the slots back in the same order: the first useState call gets slot 1, the second gets slot 2. A good mental model is a row of numbered lockers: every render, React walks down the row from locker 1, and each hook call opens the next locker. If a condition makes one call disappear, every hook after it opens the wrong locker.
From my notes: "Another example of when STRICT MODE will warn you (Hooks only at top level)" — the example itself was a screenshot that didn't come through. Correcting my notes: it isn't Strict Mode that catches this. React's development build checks the hook order on every render, with or without <StrictMode>, and the eslint-plugin-react-hooks rule rules-of-hooks flags it in your editor before the code even runs.
import { useState, useEffect } from "react";
function Search({ advanced }) {
const [query, setQuery] = useState("");
if (advanced) {
useEffect(() => {
// ❌ a hook inside a condition
}, []);
}
const [page, setPage] = useState(1);
return <p>{query}{page}</p>;
}
// First render: <Search advanced={false} />
// Second render: <Search advanced={true} />- First render with
advanced={false}: React sees two hook calls — locker 1 is auseStateholding"", locker 2 is auseStateholding1. - Second render with
advanced={true}: the calls are nowuseState,useEffect,useState. TheuseEffectcall opens locker 2 — which holdspage's state, not an effect. - React's dev build notices the type of hook in locker 2 changed and logs: "React has detected a change in the order of Hooks called by Search. This will lead to bugs and errors if not fixed." followed by a table showing
2. useStatein the previous render anduseEffectin the next, with^^^^under that row. - Then the render actually crashes. In our test React threw "Cannot read properties of undefined (reading 'length')" — the effect code tried to read a dependency array from a slot that was never an effect. There's also a third hook call with no locker at all.
- This is why the rule exists: the hook list is matched up by order, so the order must be identical on every render.
The fix is always to keep the hook call unconditional and move the condition inside it (or split the component):
function Search({ advanced }) {
const [query, setQuery] = useState("");
useEffect(() => {
if (!advanced) return; // ✅ the condition lives inside the effect
// ...advanced-only work
}, [advanced]);
const [page, setPage] = useState(1);
return <p>{query}{page}</p>;
}- Every render now calls exactly three hooks in the same order:
useState,useEffect,useState. The lockers always line up. - The effect function still runs after each render where
advancedchanged, but it returns early when there's nothing to do. Skipping work is fine; skipping the hook call is not. advancedis in the dependency array because the effect reads it — the effect re-runs when it flips.- If the advanced part needs a lot of its own state, the cleaner fix is a separate
<AdvancedFilters />component rendered conditionally. A component that isn't rendered has no lockers at all, so conditionally rendering components is always safe.
The errors you'll meet for these mistakes, as React 19.3 prints them:
| Mistake | What React says |
|---|---|
| A different hook type in the same position | "React has detected a change in the order of Hooks called by Search…" (dev warning with a table) |
| A condition adds a hook | "Rendered more hooks than during the previous render." |
An early return skips a hook | "Rendered fewer hooks than expected. This may be caused by an accidental early return statement." |
| A hook called outside a component (helper function, class, event handler) | "Invalid hook call. Hooks can only be called inside of the body of a function component." |
The order warning only fires when the type of hook in a slot changes. If the hooks are all useState, nothing warns — you just get the wrong data:
function Profile({ showBio }) {
const [name] = useState("Rohit");
if (showBio) {
const [bio] = useState("Android + React"); // ❌
return <p>{name}: {bio}</p>;
}
const [likes] = useState(3);
return <p>{name} has {likes} likes</p>;
}
// <Profile showBio={false} /> → "Rohit has 3 likes"
// <Profile showBio={true} /> → "Rohit: 3" (no warning, no error)- First render (
showBio={false}): twouseStatecalls. Locker 1 ="Rohit", locker 2 =3(likes). - Second render (
showBio={true}): the seconduseStatecall is now thebioone. It opens locker 2 — which already exists and holds3. - On re-renders
useStateignores its argument ("Android + React") because the locker already has a value, sobiois3. - Both slots are
useState, so the type check passes and React logs nothing. The page renders "Rohit: 3". We confirmed this in the sandbox: zero console errors. - This is the scariest form of the bug — it doesn't crash, it shows wrong data. It's the strongest argument for running the ESLint plugin, which catches the conditional call statically.
Rule 2 in action — a hook called from an ordinary function:
function getName() {
const [name] = useState("x"); // ❌ not a component, not a hook
return name;
}
getName();
// 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 appgetName()is called directly, not rendered by React, so there's no component "currently rendering" and no locker row to use.- Internally React keeps a "dispatcher" that's only set while it's rendering a component (see How hooks work under the hood). Outside a render it's empty, so
useStatethrows. - The three listed reasons are worth knowing for interviews: the same error appears when two copies of React end up in the bundle (common in monorepos and with
npm link), because the copy thatuseStatecame from isn't the one that's rendering. - The fix: rename the helper to
useNameand call it from a component's top level — then it's a custom hook and it's allowed to call hooks.
| Where | Allowed? |
|---|---|
| Top level of a function component | ✅ |
Top level of a custom hook (useSomething) | ✅ |
Inside if, for, while, switch | ❌ |
After an early return | ❌ |
Inside an event handler or setTimeout callback | ❌ |
Inside the callback of useEffect, useMemo, useReducer | ❌ |
| In a class component | ❌ |
use(promiseOrContext) inside an if | ✅ (React 19) |
function Survey({ questions }) {
const answers = [];
for (const q of questions) {
answers.push(useState(""));
}
// ...
}Is this a bug?
Show answer
It works while questions.length never changes, and breaks the moment it does.
- With 3 questions, every render makes 3
useStatecalls in the same order, so lockers 1–3 line up and nothing goes wrong. - If a question is added, the next render makes 4 calls: React throws "Rendered more hooks than during the previous render."
- If one is removed, it makes 2 calls: "Rendered fewer hooks than expected." And if questions are reordered, answer 2's state silently ends up on a different question.
- Fix it either with one state holding an object keyed by question id —
useState({})andsetAnswers(a => ({ ...a, [id]: value }))— or by rendering a<Question key={q.id} />component per question, each with its ownuseState. With keys, React tracks each child's state by identity, not by position.