Skip to main content

Rules for using hooks in React

React Hooks rules define where and how you can call functions like useState and useEffect inside your app. Break them and React loses track of which state belongs to which component.

Theory

TL;DR

  • Call Hooks only at the top level of a component or custom Hook, never inside conditions, loops, or nested functions
  • Call Hooks only from React functional components or custom Hooks (functions starting with use)
  • React tracks Hooks by call position, not by name. Same order every render = stable state
  • Analogy: a restaurant that prepares dishes in a fixed sequence. Skip one step and the kitchen loses track of your whole order
  • Decision rule: write all Hook calls first, then add conditions and loops below them

Quick example

tsx
// BAD: conditional hook call breaks the order function BadComponent({ showCount }: { showCount: boolean }) { if (showCount) { const [count, setCount] = useState(0); // React loses slot tracking } return <div>{showCount ? count : 'Hidden'}</div>; } // GOOD: hook always runs, condition comes after function GoodComponent({ showCount }: { showCount: boolean }) { const [count, setCount] = useState(0); // Slot 1 - always called return <div>{showCount ? count : 'Hidden'}</div>; }

BadComponent throws "Rendered more hooks than during the previous render". GoodComponent works on every render.

Why call order matters

React stores Hook state in a linked list attached to each component's fiber node. On the first render it builds that list: slot 1, slot 2, slot 3. On every re-render it walks the same list in the same order to restore each value. If a conditional skips slot 1, slot 2 gets data meant for slot 1 and everything shifts. The rule isn't arbitrary. It's a direct result of how React's fiber architecture works internally.

When to use

  • Need state or a side effect in a component - add a Hook at the top, before any return statement
  • Same stateful logic in multiple components - extract into a custom Hook named useMyLogic, not a plain function
  • Conditional logic needed - put the condition inside the Hook call or after all Hook calls
  • Loops - call one Hook outside the loop, store multiple values in an array or object keyed by index
  • Event handlers - define them inside the component, but never call Hooks inside the handler body itself

Early returns are fine. if (!userId) return null placed after your useState calls is valid React 18 code. Hooks just need to complete before any conditional exits.

How React detects violations

React's internal dispatcher switches between mount and update phases per render. During update, it checks that the number of Hook calls matches the previous render count. A mismatch triggers an immediate error from ReactCurrentDispatcher. The eslint-plugin-react-hooks catches most violations statically before runtime, which is why it ships as standard in any React project setup.

Common mistakes

Hooks inside a condition

tsx
if (isLoggedIn) { const [user, setUser] = useState(null); // shifts all subsequent slots }

Fix: call the Hook unconditionally first, then check isLoggedIn below.

Hooks inside an event handler

tsx
function handleClick() { const [local, setLocal] = useState(0); // plain JS function, not tracked by React }

Fix: move state to the component level. Use useCallback if the handler needs memoization.

Hooks inside a loop

tsx
items.map(item => { const [active, setActive] = useState(false); // new instances every render return <li>{active ? 'on' : 'off'}</li>; });

Fix: one useState at the top, store values in an object keyed by item id.

Custom Hook without use prefix

tsx
function myFetchLogic() { const data = useSWR('/api/data'); // ESLint won't flag violations inside this }

Fix: rename to useMyFetchLogic. The use prefix tells both React and ESLint this is a Hook.

Hooks outside a component

tsx
const globalCount = useState(0); // module scope, not tracked

Fix: move inside a component or into a custom Hook.

Real-world usage

  • React core: useState and useEffect in every functional component since React 16.8
  • Next.js: useSWR from Vercel for data fetching in app router pages
  • Redux Toolkit: useSelector and useDispatch in any connected component
  • TanStack Query: useQuery and useMutation, over 2 million weekly downloads
  • Zustand: useStore for lightweight global state in Vercel-built apps

In my experience reviewing code, the "hooks inside a .map()" mistake shows up most often in teams migrating from class components. The fix is always the same: one Hook, one array outside the loop.

Follow-up questions

Q: Why does React use call order instead of Hook names or IDs?
A: Names aren't unique. Two useState calls in the same component would collide. Call position gives a unique, stable slot per Hook with no extra metadata needed.

Q: Can you call a Hook after an early return?
A: No. A Hook after a return gets skipped on that render, breaking the slot count. All Hooks must run before any conditional exits.

Q: What is a custom Hook and when should you create one?
A: A function starting with use that calls other Hooks inside. Create one when the same stateful logic repeats across two or more components.

Q: How does eslint-plugin-react-hooks know where Hooks are called?
A: It parses the AST and flags any function starting with use called inside a condition, loop, or non-component function. It also enforces the use naming convention for custom Hooks.

Q (senior): What exactly happens in React's fiber when Hook order changes between renders?
A: Each fiber stores a memoizedState linked list. On update, React reads nodes in sequence. A missing node means the current node holds data from the previous node. All subsequent reads are offset by one slot. React detects the count mismatch via ReactCurrentDispatcher and throws before returning inconsistent state to the component.

Examples

Basic: Counter with two state slots

tsx
function Counter() { const [count, setCount] = useState(0); // Slot 1 const [label, setLabel] = useState('clicks'); // Slot 2 - always called return ( <div> <p>{count} {label}</p> <button onClick={() => setCount(c => c + 1)}>+1</button> </div> ); } // Output: stable count and label across all renders. Slots never shift.

Both Hooks run on every render. React restores count to slot 1 and label to slot 2 every time without confusion.

Intermediate: Todo list with filtering

tsx
function TodoList({ todos }: { todos: { id: number; text: string; done: boolean }[] }) { const [filter, setFilter] = useState<'all' | 'active' | 'done'>('all'); // Slot 1 const visibleTodos = useMemo( () => filter === 'all' ? todos : todos.filter(t => filter === 'done' ? t.done : !t.done), [todos, filter] ); // Slot 2 return ( <div> <select onChange={e => setFilter(e.target.value as any)} value={filter}> <option value="all">all</option> <option value="active">active</option> <option value="done">done</option> </select> <ul>{visibleTodos.map(t => <li key={t.id}>{t.text}</li>)}</ul> </div> ); } // Output: filter state survives re-renders. Memo skips recalculation when inputs haven't changed.

useState at slot 1, useMemo at slot 2, no conditions around either call. The filtering logic lives inside useMemo, not wrapped around the Hook itself.

Advanced: Early return after Hooks

tsx
function Profile({ userId }: { userId: string | null }) { const [profile, setProfile] = useState(null); // Slot 1 - runs every render const [posts, setPosts] = useState([]); // Slot 2 - runs every render if (!userId) return <div>No user selected</div>; // early return is fine here // fetch and render logic only when userId exists return <div>Profile for {userId}</div>; } // Output: slot count never changes because both useState calls complete before the conditional exit.

Both useState calls run before the if (!userId) check, so React always counts two Hook slots. Flip the order and you break the rule immediately.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?