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

  1. The Two Rules
  2. Rule 1: Only Call Hooks at the Top Level
  3. Why Top Level? — The Internal Mechanism
  4. Breaking Rule 1 — What Actually Goes Wrong
  5. Rule 2: Only Call Hooks from React Functions
  6. Breaking Rule 2 — What Actually Goes Wrong
  7. The ESLint Plugin: eslint-plugin-react-hooks
  8. Exhaustive Deps Rule — Deep Dive
  9. Common Violations and How to Fix Them
  10. Conditional Logic Without Breaking Rules
  11. Loops and Hooks
  12. Early Returns and Hooks
  13. Edge Cases and Nuances
  14. Rules in Custom Hooks
  15. The Future: React Compiler and Rules
  16. 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:

  1. React function components (functions that return JSX)
  2. 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

SituationSolution
Missing primitive dep (string, number, boolean)Add it to the array
Missing function depMove function inside effect OR wrap with useCallback
Missing object/array depWrap with useMemo OR move inside effect
You only need the setter from useStateSetters are stable — add them (no re-runs)
You need latest value without re-runningUse 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

NameValid Hook?Why
useAuth✅ YesStarts with "use", lowercase letter after
useFetch✅ YesStarts with "use"
useMyHook✅ YesStarts with "use"
use_auth⚠️ Works but unconventionalUnderscore after "use"
UseAuth❌ NoUppercase "U" — looks like a component
getAuth❌ NoDoesn't start with "use"
authHook❌ NoDoesn't start with "use"
USE_AUTH❌ NoAll 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:

  1. Hook calls are unconditional — compiler needs predictable hook slots
  2. Dependencies are explicit — compiler uses the dependency graph
  3. 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

  1. Two rules, zero exceptions: Call hooks at the top level only, and only from React functions (components or custom hooks).

  2. The "why" behind Rule 1: React stores hooks as a linked list indexed by call order. Changing the order between renders causes state corruption.

  3. 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.

  4. Always install the ESLint plugin (eslint-plugin-react-hooks). Set rules-of-hooks to error and exhaustive-deps to warn.

  5. Conditional logic goes INSIDE hooks, not around them: useEffect(() => { if (condition) { ... } }, [condition]);

  6. Loops → extract to child components: Each component instance gets its own stable hook count.

  7. Early returns go AFTER all hooks: Structure components as hooks → derived values → early returns → main render.

  8. Never suppress exhaustive-deps without a comment explaining why. Most warnings indicate real bugs.

  9. The React Compiler makes rules even more important — it relies on predictable hook patterns for automatic optimization.

  10. When in doubt: always call the hook, guard the logic inside. The linter is your friend.


Explain-It Challenge

  1. 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 showBonus changes from true to false:

    function Scoreboard({ showBonus }) {
      const [score, setScore] = useState(0);
      if (showBonus) {
        const [bonus, setBonus] = useState(0);
      }
      const [name, setName] = useState('Player');
    }
    
  2. 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.

  3. 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