ReactRules of Hooks

Rules of Hooks

Hooks are powerful, but they come with two strict rules. These rules are not arbitrary — they exist because React relies on the order in which hooks are called to correctly associate internal state with each hook call. Violating either rule produces subtle, hard-to-debug bugs.

Rule 1: Only Call Hooks at the Top Level

Never call hooks inside loops, conditions, or nested functions. Always call hooks at the top level of your React function, before any early returns.

JSX
// ✗ BROKEN — hook inside a condition
function SearchResults({ query }) {
  if (query) {
    const [results, setResults] = useState([])  // ← violates Rule 1
  }
  // ...
}

// ✗ BROKEN — hook inside a loop
function Dashboard({ widgets }) {
  return widgets.map(widget => {
    const [data, setData] = useState(null)  // ← violates Rule 1
    return <Widget key={widget.id} data={data} />
  })
}

// ✗ BROKEN — hook inside a nested function
function Form() {
  function validate() {
    const [error, setError] = useState('')  // ← violates Rule 1
  }
}

JSX
// ✓ CORRECT — all hooks at the top level, before any conditions
function SearchResults({ query }) {
  const [results, setResults] = useState([])  // always called, every render

  useEffect(() => {
    if (!query) return   // the condition goes INSIDE the hook
    fetchResults(query).then(setResults)
  }, [query])

  return results.map(r => <Result key={r.id} result={r} />)
}
Why Call Order Matters

React does not have access to variable names — it tracks state by the order in which hooks are called. Imagine React keeps an internal array for each component instance. The first useState call corresponds to slot 0, the second to slot 1, and so on.

JSX
function MyComponent({ showExtra }) {
  // Render 1: showExtra = false
  //   Slot 0 → useState('Alice')   => name = 'Alice'
  //   Slot 1 → useState(25)        => age = 25
  const [name, setName] = useState('Alice')
  const [age,  setAge]  = useState(25)

  // Render 2: showExtra = true — a hook is added conditionally ✗
  //   Slot 0 → useState('Active')  => name = 'Active'   (WRONG! was 'Alice')
  //   Slot 1 → useState('Alice')   => extra = 'Alice'   (WRONG! was age)
  //   Slot 2 → useState(25)        => age = 25
  if (showExtra) {
    const [extra, setExtra] = useState('Active')   // ← inserted between renders
  }
  // React now has mismatched slots and incorrect state values
}

When you add or remove a hook conditionally, the slots shift and React reads the wrong state for every hook below it. React throws a runtime error about this, but the underlying corruption may already have occurred.

Rule 2: Only Call Hooks from React Functions

Never call hooks from regular JavaScript functions. You may only call hooks from:

  • React function components

  • Custom hooks (functions whose names start with use)

JSX
// ✗ BROKEN — hook called from a plain utility function
function calculateTotal(items) {
  const [discount, setDiscount] = useState(0)   // ← violates Rule 2
  return items.reduce((sum, item) => sum + item.price, 0) * (1 - discount)
}

// ✗ BROKEN — hook called from a class method
class MyClass {
  render() {
    const [value, setValue] = useState(0)   // ← violates Rule 2
  }
}

// ✓ CORRECT — hook in a custom hook (starts with 'use')
function useDiscount() {
  const [discount, setDiscount] = useState(0)
  return { discount, setDiscount }
}

// ✓ CORRECT — hook in a function component
function PriceDisplay({ items }) {
  const { discount } = useDiscount()
  const total = items.reduce((sum, item) => sum + item.price, 0) * (1 - discount)
  return <span>{total.toFixed(2)}</span>
}
The ESLint Plugin

Catching rule violations at runtime is too late — you want errors at development time, in your editor. The official eslint-plugin-react-hooks plugin enforces both rules automatically:

Bash
npm install --save-dev eslint-plugin-react-hooks

JSON
// .eslintrc.json (or eslint.config.js)
{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}
Note
The plugin comes pre-configured in Create React App, Vite's React template, and Next.js. If you used any of these starters, you already have the rules active.
The exhaustive-deps Rule

The second rule in the plugin — exhaustive-deps — is technically not a hooks rule, but it is closely related. It warns when you use a reactive value inside a useEffect (or useCallback, useMemo) but do not include it in the dependency array. Missing dependencies cause stale closures — the effect captures an old value and never sees updates.

JSX
// ✗ exhaustive-deps warning: 'userId' is used but not in deps
useEffect(() => {
  fetchUser(userId)   // 'userId' is a reactive value (changes over time)
}, [])               // ← missing 'userId'

// ✓ Correct
useEffect(() => {
  fetchUser(userId)
}, [userId])         // ← included
Conditional Logic That Looks Like a Rule Violation

A common beginner mistake is thinking you cannot use hooks with conditions at all. The restriction is that the hook call must always run — the condition goes inside the hook's callback:

JSX
// ✗ Rule violation — hook conditionally called
function UserCard({ isLoggedIn }) {
  if (isLoggedIn) {
    const user = useCurrentUser()   // ← never do this
  }
}

// ✓ Correct — hook always called; condition is inside the effect
function UserCard({ isLoggedIn }) {
  const user = useCurrentUser()   // always called

  // Use the result conditionally in the JSX
  if (!isLoggedIn) return <GuestBanner />
  return <ProfileCard user={user} />
}

// ✓ Also correct — condition inside the hook logic
function useCurrentUser() {
  const [user, setUser] = useState(null)
  const { isLoggedIn } = useAuth()

  useEffect(() => {
    if (!isLoggedIn) return    // condition is INSIDE the effect
    fetchCurrentUser().then(setUser)
  }, [isLoggedIn])

  return user
}
The useEffect Ordering Guarantee

Because hooks always run in the same order, React can guarantee that effects from different hooks are also processed in a predictable, consistent sequence. This ordering guarantee breaks the moment a hook call is skipped in one render and not another.

Warning
If you violate the rules of hooks, React may not throw an error immediately — it depends on whether the conditional branch was taken. The bug may manifest silently as wrong state values, infinite render loops, or stale data. The ESLint plugin catches violations statically, so enable it.
Tip
When you are tempted to conditionally call a hook, ask yourself: can I move the condition *inside* the hook? That is almost always the right move, and it keeps the hook call unconditional.