Episode 2 — React Frontend Architecture NextJS / 2.7 — Useful Hooks in React
2.7.b — Rules of Hooks
In one sentence: React hooks must always be called at the top level of your component (never inside conditions, loops, or nested functions) and only from React functions — these two rules aren't style preferences but hard requirements enforced by React's internal linked-list architecture.
Navigation: ← Understanding React Hooks · Next → Commonly Used Hooks
Table of Contents
- The Two Rules
- Rule 1: Only Call Hooks at the Top Level
- Why Top Level? — The Internal Mechanism
- Breaking Rule 1 — What Actually Goes Wrong
- Rule 2: Only Call Hooks from React Functions
- Breaking Rule 2 — What Actually Goes Wrong
- The ESLint Plugin: eslint-plugin-react-hooks
- Exhaustive Deps Rule — Deep Dive
- Common Violations and How to Fix Them
- Conditional Logic Without Breaking Rules
- Loops and Hooks
- Early Returns and Hooks
- Edge Cases and Nuances
- Rules in Custom Hooks
- The Future: React Compiler and Rules
- Key Takeaways
1. The Two Rules
React hooks have exactly two inviolable rules:
┌──────────────────────────────────────────────────────────────────┐
│ RULES OF HOOKS │
│ │
│ Rule 1: Only call hooks at the TOP LEVEL │
│ ✗ Not inside conditions (if/else) │
│ ✗ Not inside loops (for/while) │
│ ✗ Not inside nested functions │
│ ✗ Not after early returns │
│ │
│ Rule 2: Only call hooks from REACT FUNCTIONS │
│ ✓ React function components │
│ ✓ Custom hooks (functions starting with "use") │
│ ✗ Not from regular JavaScript functions │
│ ✗ Not from class components │
│ ✗ Not from event handlers directly │
│ │
└──────────────────────────────────────────────────────────────────┘
These aren't suggestions. Breaking them causes silent data corruption — your component will read the wrong state and you'll spend hours debugging.
2. Rule 1: Only Call Hooks at the Top Level
"Top level" means: before any conditional logic, loops, or nested function definitions in your component body.
✅ Correct — All hooks at top level
function UserProfile({ userId }) {
// ✅ All hooks called unconditionally, in the same order every render
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const theme = useContext(ThemeContext);
const prevUserId = useRef(userId);
useEffect(() => {
setLoading(true);
fetchUser(userId).then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
// ✅ Conditional logic AFTER all hooks
if (loading) return <Spinner />;
if (!user) return <NotFound />;
return <div className={theme}>{user.name}</div>;
}
❌ Incorrect — Hooks inside conditions
function UserProfile({ userId, showDetails }) {
const [user, setUser] = useState(null);
// ❌ VIOLATION: hook inside condition
if (showDetails) {
const [details, setDetails] = useState(null); // 💥
}
// ❌ VIOLATION: hook inside condition
if (userId) {
useEffect(() => { /* fetch user */ }, [userId]); // 💥
}
return <div>{user?.name}</div>;
}
❌ Incorrect — Hooks inside loops
function ItemList({ items }) {
// ❌ VIOLATION: hook inside loop — different count each render!
for (const item of items) {
const [selected, setSelected] = useState(false); // 💥
}
return <ul>{/* ... */}</ul>;
}
❌ Incorrect — Hooks after early return
function UserProfile({ userId }) {
if (!userId) {
return <p>No user selected</p>; // Early return
}
// ❌ VIOLATION: this hook only runs when userId is truthy
const [user, setUser] = useState(null); // 💥
useEffect(() => { /* ... */ }, [userId]); // 💥
return <div>{user?.name}</div>;
}
3. Why Top Level? — The Internal Mechanism
React doesn't use hook names to identify hooks. It uses call order. This is the key to understanding the rule.
The Linked List Mechanism
On the first render, React creates a linked list of hooks:
Render 1: Component calls hooks in this order
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Position 0 → Position 1 → Position 2 → Position 3
useState useEffect useRef useContext
value: 0 deps: [] current: null value: 'light'
On subsequent renders, React walks through the same linked list, matching by position:
Render 2: React reads hooks by position
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
useState (pos 0) → reads position 0 → gets value: 0 ✓
useEffect(pos 1) → reads position 1 → gets deps: [] ✓
useRef (pos 2) → reads position 2 → gets {current:n} ✓
useContext(pos 3) → reads position 3 → gets 'light' ✓
What Happens When Order Changes
Render 1 (condition = true): 3 hooks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Position 0 Position 1 Position 2
useState useEffect useState
name: "Alice" timer effect count: 0
Render 2 (condition = false): 2 hooks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Position 0 Position 1
useState useState
reads pos 0 reads pos 1
→ "Alice" ✓ → reads TIMER EFFECT data
expecting count state 💥💥💥
React sees:
- Position 0 → useState → reads "Alice" ✓
- Position 1 → useState → reads timer effect data (expecting count!) → CRASH or silent corruption
Simplified Internal Code
// React's internal state (simplified)
let hooks = []; // Array of all hook states for this component
let currentIndex = 0; // Which hook we're currently reading
function useState(initialValue) {
const index = currentIndex; // Capture current position
currentIndex++; // Move to next position
if (isFirstRender) {
hooks[index] = { state: initialValue, setter: /* ... */ };
}
// On re-render, we READ from this exact position
return [hooks[index].state, hooks[index].setter];
}
function startRender() {
currentIndex = 0; // Reset to position 0 for each render
}
If you skip a hook with a condition, all subsequent hooks read from the wrong position in the array.
4. Breaking Rule 1 — What Actually Goes Wrong
Scenario 1: State Corruption
function BuggyForm({ showMiddleName }) {
const [firstName, setFirstName] = useState('');
if (showMiddleName) {
const [middleName, setMiddleName] = useState(''); // ← conditional hook
}
const [lastName, setLastName] = useState('');
// When showMiddleName changes from true to false:
// - firstName still reads position 0 correctly
// - lastName reads position 1, but position 1 was middleName's slot!
// - User types "Doe" in last name → it goes into middleName's state
// - When showMiddleName becomes true again → middleName shows "Doe"!
}
Scenario 2: Effect Running on Wrong Data
function BuggyChat({ roomId, showStatus }) {
const [messages, setMessages] = useState([]);
if (showStatus) {
useEffect(() => {
// Show online indicator
return () => { /* cleanup */ };
}, []);
}
// When showStatus changes, this effect may read the online indicator's
// dependency array instead of [roomId], causing it to never re-run
useEffect(() => {
const conn = connectToRoom(roomId);
return () => conn.disconnect();
}, [roomId]); // ← may read wrong deps
}
Scenario 3: React Crashes
In development mode with StrictMode, React will actually throw an error:
Error: Rendered more hooks than during the previous render.
or
Error: Rendered fewer hooks than expected. This may be caused by an
accidental early return statement.
But in production, you might get silent corruption instead of a crash — which is worse.
5. Rule 2: Only Call Hooks from React Functions
Hooks can only be called from:
- React function components (functions that return JSX)
- Custom hooks (functions whose name starts with
use)
✅ Correct Locations
// ✅ In a React function component
function Counter() {
const [count, setCount] = useState(0); // ✓
return <p>{count}</p>;
}
// ✅ In a custom hook
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth); // ✓
useEffect(() => { /* ... */ }, []); // ✓
return width;
}
❌ Incorrect Locations
// ❌ In a regular function
function getUser() {
const [user, setUser] = useState(null); // 💥 NOT a React function
return user;
}
// ❌ In a class component
class MyComponent extends React.Component {
render() {
const [count, setCount] = useState(0); // 💥 Classes can't use hooks
return <p>{count}</p>;
}
}
// ❌ In an event handler function (not a component or custom hook)
function setupEventListeners() {
useEffect(() => { // 💥 This isn't a component or hook
document.addEventListener('click', handleClick);
}, []);
}
// ❌ In a callback
function MyComponent() {
const handleClick = () => {
const [clicked, setClicked] = useState(false); // 💥 Inside nested function
};
return <button onClick={handleClick}>Click</button>;
}
// ❌ In a Promise .then
function MyComponent() {
useEffect(() => {
fetch('/api/data').then(res => {
const [data, setData] = useState(res); // 💥 Inside .then callback
});
}, []);
}
Why "use" Prefix Matters
The use prefix isn't just a convention — it's how the linter identifies custom hooks:
// The linter WILL check rules inside this function (starts with "use")
function useFormValidation(schema) {
const [errors, setErrors] = useState({}); // Linter knows this is a hook context
// Linter enforces: no conditionals before this, exhaustive deps, etc.
}
// The linter will NOT check rules inside this function
function formValidation(schema) {
const [errors, setErrors] = useState({}); // Linter sees: "hook in regular function!"
// Warning: React Hook "useState" is called in function "formValidation"
// that is neither a React function component nor a custom React Hook function.
}
// ⚠️ Misleading — starts with "use" but doesn't call hooks
function useFormatCurrency(amount) {
// No hooks called — this should be named "formatCurrency"
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
}
6. Breaking Rule 2 — What Actually Goes Wrong
Problem: No Fiber Node
When you call a hook outside of a component render, there's no Fiber node to attach the hook to. React literally doesn't know where to store the state.
// Regular function — no Fiber node exists
function fetchData(url) {
const [data, setData] = useState(null); // Where should React store this?
// There's no component instance!
// React throws: "Invalid hook call"
}
The Error Message
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
Common Cause: Event Handler Confusion
// ❌ Common mistake: defining a hook in an event handler
function SearchPage() {
const handleSearch = () => {
// This function runs AFTER render, not DURING render
const results = useFetch('/api/search'); // 💥 Invalid hook call
};
// ✅ Correct: hook at top level, trigger fetch differently
const [query, setQuery] = useState('');
const { data: results } = useFetch(query ? `/api/search?q=${query}` : null);
const handleSearch = (newQuery) => {
setQuery(newQuery); // This triggers a re-render, which re-runs useFetch
};
}
7. The ESLint Plugin: eslint-plugin-react-hooks
React provides an official ESLint plugin that enforces the rules of hooks. This is non-negotiable — always install it.
Installation
npm install --save-dev eslint-plugin-react-hooks
Configuration
// .eslintrc.json
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
Or with the new ESLint flat config:
// eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';
export default [
{
plugins: { 'react-hooks': reactHooks },
rules: {
'react-hooks/rules-of-hooks': 'error', // Enforces Rules of Hooks
'react-hooks/exhaustive-deps': 'warn', // Checks effect dependencies
},
},
];
What Each Rule Catches
rules-of-hooks (error):
- Hook called inside a condition
- Hook called inside a loop
- Hook called after early return
- Hook called in a regular function (not component/custom hook)
- Hook called in a class component
- Hook called in a nested function
exhaustive-deps (warn):
- Missing dependencies in useEffect/useMemo/useCallback
- Unnecessary dependencies
- Stale closure risks
Example Violations the Linter Catches
function MyComponent({ condition }) {
// ❌ Linter error: React Hook "useState" is called conditionally
if (condition) {
const [value, setValue] = useState(0);
}
// ❌ Linter error: React Hook "useEffect" is called conditionally
condition && useEffect(() => {}, []);
// ❌ Linter warning: React Hook useEffect has a missing dependency: 'count'
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, []); // Missing 'count' in dependency array
}
8. Exhaustive Deps Rule — Deep Dive
The exhaustive-deps rule checks that every value from the component scope that's used inside an effect (or memoization) is listed in the dependency array.
Why It Exists
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const connection = connectToRoom(roomId);
connection.onMessage(msg => setMessages(prev => [...prev, msg]));
return () => connection.disconnect();
}, []); // ⚠️ Missing dependency: roomId
// BUG: When roomId changes, the effect doesn't re-run!
// User switches from "general" to "random" but stays connected to "general"
}
The Fix
useEffect(() => {
const connection = connectToRoom(roomId);
connection.onMessage(msg => setMessages(prev => [...prev, msg]));
return () => connection.disconnect();
}, [roomId]); // ✅ Now re-runs when roomId changes
Common Situations and Solutions
// Situation 1: Function dependency
// ❌ Warning: 'fetchData' changes every render → infinite loop
function Profile({ userId }) {
const fetchData = () => fetch(`/api/users/${userId}`);
useEffect(() => {
fetchData().then(/* ... */);
}, [fetchData]); // fetchData is new every render!
// ✅ Solution A: Move function inside effect
useEffect(() => {
const fetchData = () => fetch(`/api/users/${userId}`);
fetchData().then(/* ... */);
}, [userId]);
// ✅ Solution B: Wrap with useCallback
const fetchData = useCallback(() => {
return fetch(`/api/users/${userId}`);
}, [userId]);
useEffect(() => {
fetchData().then(/* ... */);
}, [fetchData]);
}
// Situation 2: Object dependency
// ❌ Warning: 'options' is a new object every render
function Chart({ data }) {
const options = { animated: true, color: 'blue' };
useEffect(() => {
drawChart(data, options);
}, [data, options]); // options is new every render!
// ✅ Solution: useMemo the object
const options = useMemo(() => ({ animated: true, color: 'blue' }), []);
useEffect(() => {
drawChart(data, options);
}, [data, options]); // Now stable
}
// Situation 3: Intentionally omitting a dep
// Sometimes you WANT to read the latest value without re-running the effect
function Logger({ userId }) {
const [events, setEvents] = useState([]);
// I want to log events but NOT re-subscribe when events change
useEffect(() => {
const handler = (event) => {
// ⚠️ Linter warns: 'events' missing from deps
console.log('Total events:', events.length);
setEvents(prev => [...prev, event]);
};
window.addEventListener('click', handler);
return () => window.removeEventListener('click', handler);
}, []); // I intentionally don't want [events] here
// ✅ Solution: use ref for latest value
const eventsRef = useRef(events);
eventsRef.current = events;
useEffect(() => {
const handler = (event) => {
console.log('Total events:', eventsRef.current.length); // Latest!
setEvents(prev => [...prev, event]);
};
window.addEventListener('click', handler);
return () => window.removeEventListener('click', handler);
}, []); // No warning — ref is stable
}
When to Suppress the Warning
Almost never. But when you must:
useEffect(() => {
// eslint-disable-next-line react-hooks/exhaustive-deps
analytics.track('page_view', { page: pathname });
}, []); // Intentionally fire once, ignore pathname changes
// ALWAYS add a comment explaining WHY you're suppressing
Decision Table: What to Do with Dep Warnings
| Situation | Solution |
|---|---|
| Missing primitive dep (string, number, boolean) | Add it to the array |
| Missing function dep | Move function inside effect OR wrap with useCallback |
| Missing object/array dep | Wrap with useMemo OR move inside effect |
| You only need the setter from useState | Setters are stable — add them (no re-runs) |
| You need latest value without re-running | Use a ref |
| You genuinely want "fire once" | Suppress with comment |
9. Common Violations and How to Fix Them
Violation 1: Conditional Hook
// ❌ BAD
function Profile({ isLoggedIn }) {
if (isLoggedIn) {
const [user, setUser] = useState(null);
}
// ✅ FIX: Always call, handle condition in the logic
const [user, setUser] = useState(null);
// Use 'user' only when isLoggedIn is true
}
Violation 2: Hook in Loop
// ❌ BAD
function TodoList({ todos }) {
const states = [];
for (const todo of todos) {
const [checked, setChecked] = useState(todo.done);
states.push({ checked, setChecked });
}
// ✅ FIX: Lift state to parent, or use a single state object
const [checkedMap, setCheckedMap] = useState(() =>
Object.fromEntries(todos.map(t => [t.id, t.done]))
);
// Or extract to a child component:
return todos.map(todo => <TodoItem key={todo.id} todo={todo} />);
}
function TodoItem({ todo }) {
const [checked, setChecked] = useState(todo.done); // ✅ Each item is its own component
return <label><input type="checkbox" checked={checked} onChange={() => setChecked(!checked)} />{todo.text}</label>;
}
Violation 3: Hook After Early Return
// ❌ BAD
function UserCard({ user }) {
if (!user) return null; // Early return BEFORE hooks
const [expanded, setExpanded] = useState(false); // 💥
// ✅ FIX: Move hooks before any returns
const [expanded, setExpanded] = useState(false);
if (!user) return null; // Early return AFTER hooks ✓
return <div>{expanded ? user.bio : user.name}</div>;
}
Violation 4: Hook Inside Nested Function
// ❌ BAD
function App() {
const renderHeader = () => {
const [title, setTitle] = useState('Hello'); // 💥 nested function
return <h1>{title}</h1>;
};
// ✅ FIX: Extract to a component
return <Header />;
}
function Header() {
const [title, setTitle] = useState('Hello'); // ✅ top level of component
return <h1>{title}</h1>;
}
Violation 5: Hook in Try/Catch
// ❌ BAD (technically a violation — hooks must be unconditional)
function Risky() {
try {
const [data, setData] = useState(null); // 💥
} catch (e) {
// ...
}
// ✅ FIX: Hook at top level, try/catch in effect
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
try {
const result = riskyOperation();
setData(result);
} catch (e) {
setError(e);
}
}, []);
}
10. Conditional Logic Without Breaking Rules
The pattern is: always call the hook, put the condition in the logic.
Pattern 1: Conditional Effect Execution
// ❌ Don't skip the hook
if (shouldFetch) {
useEffect(() => { fetch(url); }, [url]); // 💥
}
// ✅ Call hook always, skip logic inside
useEffect(() => {
if (!shouldFetch) return; // Skip inside the effect
fetch(url).then(/* ... */);
}, [shouldFetch, url]);
Pattern 2: Conditional State Usage
// ❌ Don't conditionally create state
if (showSearch) {
const [query, setQuery] = useState(''); // 💥
}
// ✅ Always create state, conditionally render UI
const [query, setQuery] = useState('');
return (
<div>
{showSearch && <input value={query} onChange={e => setQuery(e.target.value)} />}
</div>
);
Pattern 3: Conditional Data Fetching
// ✅ Common pattern: pass null/undefined to disable
function useUser(userId) {
const [user, setUser] = useState(null);
useEffect(() => {
if (!userId) return; // Guard clause inside effect
let cancelled = false;
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => { if (!cancelled) setUser(data); });
return () => { cancelled = true; };
}, [userId]);
return user;
}
// Usage:
const user = useUser(isLoggedIn ? userId : null); // null = disabled
Pattern 4: Extract to Component
// When conditional logic is complex, extract to a child component
function Dashboard({ user }) {
return (
<div>
<Header />
{user.isPremium && <PremiumFeatures userId={user.id} />}
{/* PremiumFeatures can use any hooks it wants! */}
</div>
);
}
function PremiumFeatures({ userId }) {
// ✅ All hooks called unconditionally within THIS component
const [features, setFeatures] = useState([]);
useEffect(() => {
fetchPremiumFeatures(userId).then(setFeatures);
}, [userId]);
return <ul>{features.map(f => <li key={f.id}>{f.name}</li>)}</ul>;
}
11. Loops and Hooks
You can never call hooks inside a loop because the number of hook calls would vary.
The Problem
// ❌ If items has 3 elements one render and 5 the next,
// React sees 3 hooks then 5 hooks — positions don't match
function ItemList({ items }) {
return items.map(item => {
const [selected, setSelected] = useState(false); // 💥
return <div key={item.id}>{item.name}</div>;
});
}
Solution 1: Extract to Child Component (Best)
function ItemList({ items }) {
return items.map(item => (
<Item key={item.id} item={item} />
));
}
function Item({ item }) {
// ✅ Each Item is its own component with stable hook count
const [selected, setSelected] = useState(false);
const [hovered, setHovered] = useState(false);
return (
<div
className={selected ? 'selected' : ''}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => setSelected(!selected)}
>
{item.name}
</div>
);
}
Solution 2: Aggregate State in Parent
function ItemList({ items }) {
// ✅ Single state object for all items
const [selectedIds, setSelectedIds] = useState(new Set());
const toggleSelect = (id) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return items.map(item => (
<div
key={item.id}
className={selectedIds.has(item.id) ? 'selected' : ''}
onClick={() => toggleSelect(item.id)}
>
{item.name}
</div>
));
}
Solution 3: useReducer for Complex List State
function ItemList({ items }) {
const [state, dispatch] = useReducer(listReducer, {
selectedIds: new Set(),
expandedIds: new Set(),
editingId: null,
});
return items.map(item => (
<div key={item.id}>
<span onClick={() => dispatch({ type: 'TOGGLE_SELECT', id: item.id })}>
{item.name}
</span>
{state.expandedIds.has(item.id) && <ItemDetails item={item} />}
</div>
));
}
function listReducer(state, action) {
switch (action.type) {
case 'TOGGLE_SELECT': {
const next = new Set(state.selectedIds);
next.has(action.id) ? next.delete(action.id) : next.add(action.id);
return { ...state, selectedIds: next };
}
case 'TOGGLE_EXPAND': { /* ... */ }
case 'SET_EDITING': { /* ... */ }
default: return state;
}
}
12. Early Returns and Hooks
Hooks must be called before any return statement.
The Problem
function UserProfile({ userId }) {
// ❌ On renders where userId is falsy, hooks below are skipped
if (!userId) return <p>Select a user</p>;
const [user, setUser] = useState(null); // Skipped when !userId
const [loading, setLoading] = useState(true); // Skipped when !userId
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // Skipped when !userId
}
The Fix: Hooks First, Returns After
function UserProfile({ userId }) {
// ✅ ALL hooks called unconditionally
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!userId) return; // Guard inside effect
fetchUser(userId).then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
// Early returns AFTER all hooks
if (!userId) return <p>Select a user</p>;
if (loading) return <Spinner />;
if (!user) return <NotFound />;
return <div>{user.name}</div>;
}
Guideline for Component Structure
function MyComponent(props) {
// ZONE 1: All hooks (useState, useEffect, useRef, useContext, etc.)
const [state, setState] = useState(initial);
const ref = useRef(null);
useEffect(() => { /* ... */ }, []);
// ZONE 2: Derived values and handlers
const derivedValue = computeSomething(state);
const handleClick = () => setState(s => s + 1);
// ZONE 3: Early returns (loading, error, empty states)
if (loading) return <Spinner />;
if (error) return <Error />;
// ZONE 4: Main render
return <div>{/* ... */}</div>;
}
13. Edge Cases and Nuances
Can I Use Hooks in Async Functions?
// ❌ NO — async functions are called outside render cycle
const fetchUser = async (userId) => {
const [user, setUser] = useState(null); // 💥
};
// ✅ Use hooks in the component, async logic in effects
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
// Async function INSIDE the effect
const loadUser = async () => {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
};
loadUser();
}, [userId]);
}
Can I Use Hooks in TypeScript Generics?
// ✅ Yes — generics don't affect hook rules
function useGenericState<T>(initialValue: T) {
const [value, setValue] = useState<T>(initialValue);
return [value, setValue] as const;
}
What About && Short-Circuit?
// ❌ This IS a conditional hook call
condition && useEffect(() => {}, []); // 💥
// The && operator is equivalent to if(condition)
What About Ternary?
// ❌ This is also conditional
const value = condition ? useState(0) : useState(1); // 💥
// Different hook CALLS depending on condition
// ✅ This is fine — same hook called unconditionally, different VALUE
const [value, setValue] = useState(condition ? 0 : 1);
// The condition is in the INITIAL VALUE, not the hook call
Switch Statements?
// ❌ Switch inside hook calls
function MyComponent({ type }) {
switch (type) {
case 'a': const [a, setA] = useState(0); break; // 💥
case 'b': const [b, setB] = useState(0); break; // 💥
}
// ✅ Extract to separate components
switch (type) {
case 'a': return <TypeA />;
case 'b': return <TypeB />;
}
}
Hooks in Custom Hook Early Return?
// ❌ Custom hooks follow the same rules
function useFetch(url) {
if (!url) return { data: null, loading: false }; // Early return BEFORE hooks
const [data, setData] = useState(null); // 💥 Conditional!
// ✅ FIX: hooks first, guard inside
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!url) return;
setLoading(true);
fetch(url).then(res => res.json()).then(setData).finally(() => setLoading(false));
}, [url]);
return { data, loading };
}
14. Rules in Custom Hooks
Custom hooks must follow all the same rules:
// ✅ Custom hook with correct rule adherence
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// ✅ Custom hook composing other hooks
function useDebouncedSearch(query) {
const debouncedQuery = useDebounce(query, 300); // Hook 1
const results = useFetch( // Hook 2
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
);
return results;
}
// ❌ Custom hook with conditional hook call
function useBadHook(condition) {
if (condition) {
return useState(true); // 💥 Conditional
}
return useState(false); // 💥 Different hook count depending on condition
// ✅ FIX
const [value, setValue] = useState(condition); // One hook, always called
return [value, setValue];
}
Custom Hook Naming Rules
| Name | Valid Hook? | Why |
|---|---|---|
useAuth | ✅ Yes | Starts with "use", lowercase letter after |
useFetch | ✅ Yes | Starts with "use" |
useMyHook | ✅ Yes | Starts with "use" |
use_auth | ⚠️ Works but unconventional | Underscore after "use" |
UseAuth | ❌ No | Uppercase "U" — looks like a component |
getAuth | ❌ No | Doesn't start with "use" |
authHook | ❌ No | Doesn't start with "use" |
USE_AUTH | ❌ No | All caps — looks like a constant |
15. The Future: React Compiler and Rules
The React Compiler (formerly "React Forget") is being developed to automatically apply memoization optimizations. But it relies on the Rules of Hooks being followed.
What the Compiler Does
// What you write
function ProductPage({ product }) {
const [quantity, setQuantity] = useState(1);
const total = product.price * quantity;
return (
<div>
<ProductDetails product={product} />
<PriceDisplay total={total} />
<QuantityPicker value={quantity} onChange={setQuantity} />
</div>
);
}
// What the compiler generates (conceptually)
function ProductPage({ product }) {
const [quantity, setQuantity] = useState(1);
const total = useMemo(() => product.price * quantity, [product.price, quantity]);
return (
<div>
{useMemo(() => <ProductDetails product={product} />, [product])}
{useMemo(() => <PriceDisplay total={total} />, [total])}
<QuantityPicker value={quantity} onChange={setQuantity} />
</div>
);
}
Why Rules Matter Even More with the Compiler
The compiler performs static analysis of your code. It can only work if:
- Hook calls are unconditional — compiler needs predictable hook slots
- Dependencies are explicit — compiler uses the dependency graph
- Components are pure — no side effects in render
┌──────────────────────────────────────────────────────┐
│ Without Compiler │ With Compiler │
├──────────────────────────┼────────────────────────────┤
│ Manual useMemo │ Auto-memoized │
│ Manual useCallback │ Auto-memoized │
│ Manual React.memo │ Auto-applied │
│ Rules = prevent bugs │ Rules = prevent bugs + │
│ │ enable optimizations │
└──────────────────────────┴────────────────────────────┘
The React 19+ Landscape
2024-2025: Compiler opt-in, rules enforced by linter
2025-2026: Compiler default in new projects
Future: Compiler may REQUIRE rule adherence
(currently gracefully degrades)
16. Key Takeaways
-
Two rules, zero exceptions: Call hooks at the top level only, and only from React functions (components or custom hooks).
-
The "why" behind Rule 1: React stores hooks as a linked list indexed by call order. Changing the order between renders causes state corruption.
-
The "why" behind Rule 2: Hooks attach state to a component's Fiber node. Outside a component render, there's no Fiber node — React doesn't know where to store the data.
-
Always install the ESLint plugin (
eslint-plugin-react-hooks). Setrules-of-hookstoerrorandexhaustive-depstowarn. -
Conditional logic goes INSIDE hooks, not around them:
useEffect(() => { if (condition) { ... } }, [condition]); -
Loops → extract to child components: Each component instance gets its own stable hook count.
-
Early returns go AFTER all hooks: Structure components as hooks → derived values → early returns → main render.
-
Never suppress exhaustive-deps without a comment explaining why. Most warnings indicate real bugs.
-
The React Compiler makes rules even more important — it relies on predictable hook patterns for automatic optimization.
-
When in doubt: always call the hook, guard the logic inside. The linter is your friend.
Explain-It Challenge
-
The Broken Scoreboard: A junior dev wrote this code and state keeps getting mixed up. Explain exactly what goes wrong step-by-step across two renders when
showBonuschanges fromtruetofalse:function Scoreboard({ showBonus }) { const [score, setScore] = useState(0); if (showBonus) { const [bonus, setBonus] = useState(0); } const [name, setName] = useState('Player'); } -
The Rule Enforcer: Your teammate argues "The rules of hooks are just a convention — I can work around them with careful coding." Explain why the rules are a technical necessity, not a style preference. Use the linked list mechanism.
-
The Refactoring Challenge: Given this component that violates the rules, refactor it to follow them while keeping the same behavior:
function Dashboard({ isAdmin }) { if (!isAdmin) return <UserDashboard />; const [stats, setStats] = useState(null); useEffect(() => { fetchAdminStats().then(setStats); }, []); return <AdminDashboard stats={stats} />; }
Navigation: ← Understanding React Hooks · Next → Commonly Used Hooks