Rules & Custom Hooks
The two rules
Section titled “The two rules”There are only two Rules of Hooks:
- Only call hooks at the top level — never inside conditions, loops, or nested functions.
- Only call hooks from React functions — components or other hooks, not plain functions.
function Profile({ userId }) { const [user, setUser] = useState(null); // ✅ top level
if (!userId) { const [error, setError] = useState(null); // ❌ conditional — breaks the rules }
for (const id of ids) { useEffect(() => { /* ... */ }); // ❌ in a loop — breaks the rules }}Why the first rule exists
Section titled “Why the first rule exists”Remember from the module intro: React matches each hook call to a memory slot by call order, not by name. The first useState is slot 1, the second is slot 2, every render.
flowchart TB r1["Render 1: useState, useState, useEffect slots 1, 2, 3"] --> ok["consistent"] r2["Render 2: (condition false) useState, useEffect slots 1, 2"] --> bad["useEffect now reads slot 2 — the state of the wrong hook"]
If you call a hook conditionally, then on a render where the condition differs, the count and order of hook calls changes — and every hook after the conditional one reads the wrong slot. State bleeds between hooks; chaos follows. Calling hooks unconditionally, in the same order every render, is what keeps the slot mapping stable. That’s the entire reason for the rule.
Enforce it automatically with the official lint rules (eslint-plugin-react-hooks) — they catch conditional hooks and missing effect dependencies before they ship.
Proof: the real error, not just a lint warning
Section titled “Proof: the real error, not just a lint warning”The Rules of Hooks aren’t only a lint convention — breaking them at runtime, in a way ESLint’s static analysis doesn’t catch (for example, a condition that only sometimes reduces the hook count), throws a real React error, not a silent bug:
import { useState } from 'react';import { render, cleanup } from '@testing-library/react';import { afterEach, expect, test, vi } from 'vitest';
afterEach(cleanup);
function Buggy({ skip }: { skip: boolean }) { const [a] = useState('a'); if (!skip) { // 🔴 Conditional hook call — only runs on some renders. // eslint-disable-next-line react-hooks/rules-of-hooks useState('b'); } return <span>{a}</span>;}
test('calling a hook conditionally on re-render throws React\'s real "Rendered fewer hooks" error', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); const { rerender } = render(<Buggy skip={false} />); // useState('a'), useState('b') expect(() => { rerender(<Buggy skip />); // this render only calls useState('a') — count changed }).toThrowError(/Rendered fewer hooks than expected/); spy.mockRestore();});✓ calling a hook conditionally on re-render throws React's real "Rendered fewer hooks" error
Test Files 1 passed (1) Tests 1 passed (1)The exact message React throws, captured in the probe:
Rendered fewer hooks than expected. This may be caused by an accidental early return statement.That second sentence is a real clue: the far more common real-world trigger isn’t a hand-written if around a hook — it’s an early return placed before a hook that used to run on every render.
Custom hooks: sharing stateful logic
Section titled “Custom hooks: sharing stateful logic”A custom hook is simply a function whose name starts with use and which calls other hooks. That’s it — there’s no special API. It lets you extract and reuse stateful logic (not UI) across components.
// A custom hook: reusable logic, its own state, calls built-in hooks.function useOnlineStatus() { const [isOnline, setIsOnline] = useState(navigator.onLine); useEffect(() => { const on = () => setIsOnline(true); const off = () => setIsOnline(false); window.addEventListener('online', on); window.addEventListener('offline', off); return () => { window.removeEventListener('online', on); window.removeEventListener('offline', off); }; }, []); return isOnline;}
// Any component can now use it:function StatusBar() { const isOnline = useOnlineStatus(); return <span>{isOnline ? '✅ Online' : '❌ Offline'}</span>;}The crucial thing to understand: custom hooks share logic, not state. Each component that calls useOnlineStatus() gets its own independent state — the hook is a recipe that’s run fresh per component, not a shared store. (For shared state across components, you lift state up or use context/an external store — the next module.)
When to extract a custom hook: when two components need the same stateful behavior, or when a component’s logic gets complex enough that naming it (useForm, useDebouncedValue, useFetch) makes the component clearer.
A well-written custom hook returns stable functions
Section titled “A well-written custom hook returns stable functions”A custom hook that hands back a callback (for a memoized child, or an effect dependency elsewhere) should return the same function reference across re-renders whenever nothing it depends on changed — exactly like you’d want from any function you write by hand. useCallback inside the hook is what makes that true:
import { useCallback, useState } from 'react';
function useCounter() { const [count, setCount] = useState(0); const increment = useCallback(() => setCount((c) => c + 1), []); return { count, increment };}
export default useCounter;import { useCallback, useState } from 'react';import { act, render, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
function useCounter() { const [count, setCount] = useState(0); const increment = useCallback(() => setCount((c) => c + 1), []); return { count, increment };}
test('a well-written custom hook returns the same function reference across re-renders', () => { const seen: (() => void)[] = []; function Probe({ unrelated }: { unrelated: number }) { const { increment } = useCounter(); seen.push(increment); return <span>{unrelated}</span>; } const { rerender } = render(<Probe unrelated={1} />); rerender(<Probe unrelated={2} />); // re-render for an unrelated reason expect(seen).toHaveLength(2); expect(seen[0]).toBe(seen[1]); // same reference, not a new one each render});
test('calling increment() twice via act reaches count 2', () => { function Probe() { const { count, increment } = useCounter(); return <button onClick={increment}>{count}</button>; } const { getByRole } = render(<Probe />); act(() => getByRole('button').click()); act(() => getByRole('button').click()); expect(getByRole('button').textContent).toBe('2');});✓ a well-written custom hook returns the same function reference across re-renders✓ calling increment() twice via act reaches count 2
Test Files 1 passed (1) Tests 2 passed (2)If useCounter had written const increment = () => setCount(c => c + 1); without useCallback, the first test would fail: every render would hand back a brand-new function, which is exactly the kind of thing that defeats a React.memo child or forces an effect depending on it to re-run needlessly (see the memoization lesson).
The use exception to the Rules of Hooks
Section titled “The use exception to the Rules of Hooks”use (introduced alongside Server Components, and usable in Client Components too for unwrapping a promise handed down from the server) is explicitly not bound by the “only at the top level” rule the way useState/useEffect/etc. are. It’s the one built-in that’s designed to be called inside conditionals, loops, and after early returns:
import { use } from 'react';
function Message({ show, promise }: { show: boolean; promise: Promise<string> }) { // ✅ `use` is allowed after a conditional early return — a real hook // (useState/useEffect/etc.) would violate the Rules of Hooks here. if (!show) return <p>hidden</p>; const text = use(promise); return <p>{text}</p>;}
export default Message;import { Suspense, use } from 'react';import { act, render, cleanup, screen, waitFor } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
function Message({ show, promise }: { show: boolean; promise: Promise<string> }) { if (!show) return <p>hidden</p>; const text = use(promise); return <p>{text}</p>;}
test('use() may be called after an early return, unlike a real Hook', async () => { const promise = Promise.resolve('loaded'); await act(async () => { render( <Suspense fallback={<p>loading</p>}> <Message show promise={promise} /> </Suspense>, ); }); await waitFor(() => expect(screen.getByText('loaded')).toBeTruthy());});✓ use() may be called after an early return, unlike a real Hook
Test Files 1 passed (1) Tests 1 passed (1)Don’t take this as license to call useState after a conditional too — use is a special-cased exception (it’s not tracked by React’s per-render hook-slot list the way ordinary hooks are), not a loophole in the rule itself. The rule still applies to every hook whose name you didn’t get from React’s own use() API.
Mental Model
Section titled “Mental Model”The Rules of Hooks are not a style guideline layered on top of React — they’re a direct consequence of the memory-slot mechanism from the module intro. If hooks were matched by name, conditional calls would be perfectly fine. Because they’re matched by position in a fixed, ordered list, the list’s shape must be identical every render, full stop. use gets to break this because it isn’t part of that list at all — it reads directly from a Suspense-aware cache keyed on the promise/context you pass it, which is precisely why it doesn’t need positional stability.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Enforcing rules via ESLint (eslint-plugin-react-hooks) | Catches most violations before they ever run | Some violations (a condition that only sometimes differs) only surface at runtime |
Custom hook with useCallback-wrapped returns | Safe to pass to memoized children / effect deps | Slightly more code in the hook itself |
use() for conditional data unwrapping | Lets you skip fetching/suspending work you don’t need | Only works for promises and context — not a general escape hatch for other hooks |
Common Misconceptions
Section titled “Common Misconceptions”- “Breaking the Rules of Hooks is just a lint warning, not a real bug.” It can throw a genuine runtime error — “Rendered fewer hooks than expected” — proven above, not merely a linter complaint.
- “An early
returnbefore a hook is fine as long as the hook itself isn’t inside anif.” It’s exactly as dangerous: the early return skips the hook call on some renders, which is the same “count changed” problem a conditional causes. - “Custom hooks automatically return stable references.” They don’t — a custom hook is just a function; if it hands back a fresh closure every call without
useCallback, callers get a new reference every render, same as if they’d written it inline. - “
useproves you can call any hook conditionally if you’re careful.”useis a special, explicitly-documented exception built differently from ordinary hooks — it does not generalize touseState,useEffect, or any hook you or a library author writes.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated custom hook or component uses hooks, check for these before trusting it:
- Is any hook called after an early
return, inside anif/for/while, or inside a nested callback — anything other than the top level of a component or another hook? - Does a custom hook return a function without wrapping it in
useCallback, when that function is passed to a memoized child or used as an effect dependency elsewhere? - Is
use()being treated as safe for conditional calls, but the code actually callsuseState/useEffect/etc. conditionally too, assuming the same exception applies? - Does a custom hook silently share state across components (e.g., via a module-level variable) when it should give each caller independent state?
- Would the component’s hook count actually change between renders for some prop combination that isn’t immediately obvious from reading the top of the function?
🐛 Spot the bug
function Profile({ userId }) {const [user, setUser] = useState(null);
if (!userId) { return <p>No user selected</p>;}
useEffect(() => { fetchUser(userId).then(setUser);}, [userId]);
return <p>{user?.name}</p>;}Show the answer
The useEffect call sits after an early return — when userId is falsy, this render calls only one hook (useState); when it’s truthy, this render calls two (useState then useEffect). That is exactly the “hook count changes between renders” problem the Rules of Hooks exist to prevent, and it is not a style nit: proven earlier in this lesson, a component whose hook count drops between renders throws React’s real runtime error, “Rendered fewer hooks than expected. This may be caused by an accidental early return statement.” — which is precisely what happened here. The fix is to call useEffect (and any other hook) before any conditional return, and put the “should I even fetch” check inside the effect body instead: useEffect(() => { if (!userId) return; fetchUser(userId).then(setUser); }, [userId]);.