Skip to content

Rules & Custom Hooks

There are only two Rules of Hooks:

  1. Only call hooks at the top level — never inside conditions, loops, or nested functions.
  2. 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
}
}

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"]
A conditional hook shifts every later slot

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:

src/__tests__/conditional-hook-error.test.tsx
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.

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:

src/hooks/useCounter.ts
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;
src/__tests__/custom-hook-stable-fn.test.tsx
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).

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:

src/components/Message.tsx
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;
src/__tests__/use-conditional.test.tsx
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.

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.

ChoiceBenefitCost
Enforcing rules via ESLint (eslint-plugin-react-hooks)Catches most violations before they ever runSome violations (a condition that only sometimes differs) only surface at runtime
Custom hook with useCallback-wrapped returnsSafe to pass to memoized children / effect depsSlightly more code in the hook itself
use() for conditional data unwrappingLets you skip fetching/suspending work you don’t needOnly works for promises and context — not a general escape hatch for other hooks
  • “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 return before a hook is fine as long as the hook itself isn’t inside an if.” 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.
  • “use proves you can call any hook conditionally if you’re careful.” use is a special, explicitly-documented exception built differently from ordinary hooks — it does not generalize to useState, useEffect, or any hook you or a library author writes.

When a generated custom hook or component uses hooks, check for these before trusting it:

  • Is any hook called after an early return, inside an if/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 calls useState/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]);.

What are the two Rules of Hooks?
Why can you not call a hook conditionally?
What is a custom hook?
What happens when userId goes from truthy to falsy across two renders?
function Profile({ userId }) {
  const [user, setUser] = useState(null);
  if (!userId) {
    return <p>No user</p>;
  }
  useEffect(() => { fetchUser(userId).then(setUser); }, [userId]);
  return <p>{user?.name}</p>;
}
A custom hook returns a callback without wrapping it in useCallback. What is the consequence for a caller that passes it to a React.memo child?
Why is use() allowed after a conditional early return, when useState is not?