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.
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.
- 5 React Hooks Part 5: Sharing State with useContext
- 6 React Hooks Part 6: useId, useTransition, and useDeferredValue
- 7 React Hooks Part 7: useImperativeHandle and useLayoutEffect
- 8 React Hooks Part 8: Rules, Anti-Patterns, and the ESLint Plugin
- 9 React Hooks Part 9: Testing Hooks with React Testing Library
Table of Contents
The two rules
React Hooks have exactly two rules:
- Only call hooks at the top level — never inside loops, conditions, or nested functions.
- 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.
// 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:
npm install --save-dev eslint-plugin-react-hooks
// .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
// 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
// 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
// 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
// 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
// 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-hooksand set both rules toerror/warn. - Stale closures come from missing deps — use the updater form or restructure.
useCallback/useMemoonly pay off when children are memoized or values are expensive.