TUTORIAL

React Hooks Part 8: Rules, Anti-Patterns, and the ESLint Plugin

Hooks come with two hard rules enforced by the ESLint plugin. This article explains why those rules exist, the most common anti-patterns developers fall into, and how to restructure code when you feel the urge to break them.


SERIES

React Hooks: A Complete Guide

A four-part deep-dive into React Hooks — from managing state with useState to squeezing performance out of useCallback and useMemo. Each article is standalone yet builds on the previous, giving you both quick reference and progressive mastery.

Table of Contents

The two rules

React Hooks have exactly two rules:

  1. Only call hooks at the top level — never inside loops, conditions, or nested functions.
  2. Only call hooks from React functions — from function components or custom hooks, never from regular JS functions or class components.

These aren't style guidelines. Violating them produces subtle, hard-to-reproduce bugs.

Why the rules exist: the call order contract

React tracks state by call order. On every render, React expects hook call N to correspond to the same piece of state as hook call N in the previous render. If a hook is called conditionally, the call order changes and every subsequent hook reads the wrong state.

JSX
// This is broken
function BrokenComponent({ isLoggedIn }) {
  if (isLoggedIn) {
    const [user, setUser] = useState(null)  // hook #1 only sometimes
  }
  const [theme, setTheme] = useState('light')  // might be hook #1 or #2
}

React has no names for hooks — just positions. Skip one and everything after it is off by one.

The ESLint plugin

Install once, never think about this again:

Bash
npm install --save-dev eslint-plugin-react-hooks
JSON
// .eslintrc
{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

rules-of-hooks catches rule violations. exhaustive-deps catches missing dependency array entries — the most common source of subtle bugs.

Common anti-patterns

1. Conditional hooks

JSX
// Bad
function Component({ show }) {
  if (show) {
    useEffect(() => { … }, [])
  }
}

// Good: condition goes inside the hook
function Component({ show }) {
  useEffect(() => {
    if (!show) return
    // …
  }, [show])
}

2. Hooks inside loops

JSX
// Bad
function List({ items }) {
  return items.map(item => {
    const [selected, setSelected] = useState(false)  // different count each render!
    return <Item key={item.id} selected={selected} onSelect={setSelected} />
  })
}

// Good: lift state up, or extract a component
function SelectableItem({ item }) {
  const [selected, setSelected] = useState(false)
  return <Item selected={selected} onSelect={setSelected} />
}

3. Stale closures in useEffect

JSX
// Bad: count inside the closure is stale
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1)  // always reads initial value of count
  }, 1000)
  return () => clearInterval(id)
}, [])  // missing count in deps

// Good: use the updater form
useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1)  // no dependency needed
  }, 1000)
  return () => clearInterval(id)
}, [])

4. Object/function as dependency

JSX
// Infinite loop: options is a new object every render
useEffect(() => {
  fetchData(options)
}, [options])

// Good: destructure to primitives
const { page, limit } = options
useEffect(() => {
  fetchData({ page, limit })
}, [page, limit])

5. Overusing useCallback and useMemo

JSX
// Pointless: the memoisation cost > the render cost
const double = useMemo(() => x * 2, [x])

// Pointless: child isn't memoized so the stable ref does nothing
const onClick = useCallback(() => doThing(), [])

Only reach for these when you have a measured performance problem, not as a default.

Reading the exhaustive-deps warning

When eslint-plugin-react-hooks warns about a missing dependency, resist the urge to just add it to the array. First ask: why is this value changing?

  • If it changes too often, stabilise it with useCallback/useMemo.
  • If it should never re-trigger the effect, it probably belongs inside the effect or in a ref.
  • If it truly should be a dependency, add it.

The warning is never wrong — but the fix isn't always "add to the array."

Key takeaways

  • Hooks must be called in the same order every render — no conditions, no loops.
  • Install eslint-plugin-react-hooks and set both rules to error/warn.
  • Stale closures come from missing deps — use the updater form or restructure.
  • useCallback/useMemo only pay off when children are memoized or values are expensive.

Was this article helpful?

w

webencher Editorial

Software engineers and technical writers with 10+ years of combined experience in algorithms, systems design, and web development. Every article is reviewed for accuracy, depth, and practical applicability.

More by this author →