React: Hooks Rules and Common Pitfalls
Last updated: 2026-08-26
1. What You'll Learn
- The Two Iron Laws of Hooks (Why You Can’t Call Them Conditionally)
- Identifying and Fixing Stale Closures
- 5 Reasons for an Infinite Loop in
useEffect - Pitfalls of Asynchronous Updates with useState
- Solutions for the 10 Most Common Bugs
2. A Story of a Debugging Nightmare
(1) Pain Point: It took 3 hours to debug a single useEffect
Alice wrote a countdown component:
▶ Example: Hooks Rule Demo
Output:
State: timeLeft. side effects via useEffect. interval updates
function Countdown({ targetDate }) {
const [timeLeft, setTimeLeft] = useState(computeTimeLeft())
function computeTimeLeft() {
return Math.max(0, Math.floor((new Date(targetDate) - Date.now()) / 1000))
}
useEffect(() => {
const timer = setInterval(() => {
// ❌ Closure Pitfalls:timeLeft Always the initial value
setTimeLeft(timeLeft - 1)
}, 1000)
return () => clearInterval(timer)
}, []) // I want it to start only when mounted
return <p>Remaining:{timeLeft}s</p>
}
Output:
useEffect manages side effects
Bug: The countdown always stops at the initial value of -1. This is because the timeLeft captured in the setInterval callback is the value from the first render (closure trap).
(2) Fix
// ✅ Fix 1:Functional Updates
useEffect(() => {
const timer = setInterval(() => {
setTimeLeft(prev => Math.max(0, prev - 1)) // prev Always the latest value
}, 1000)
return () => clearInterval(timer)
}, [])
// ✅ Fix 2:Or put targetDate Add to the dependencies array
useEffect(() => {
const timer = setInterval(() => {
setTimeLeft(prev => Math.max(0, prev - 1))
}, 1000)
return () => clearInterval(timer)
}, [targetDate]) // targetDate Restart the timer when a change occurs
Lesson: Never read the state directly in useEffect to update yourself. Use functional updates setState(prev => prev + 1).
3. The Two Ironclad Rules of Hooks
(1) Rule 1: Call hooks only at the top level
Do not call hooks inside loops, conditional statements, or nested functions:
function BadComponent({ showExtra }) {
const [name, setName] = useState('') // ✅ Top Floor
// ❌ Rule Violation:Called within a condition Hook
if (showExtra) {
const [extra, setExtra] = useState('') // Error!
useEffect(() => { /* ... */ }, [])
}
// ❌ Rule Violation:Called within a loop Hook
for (let i = 0; i < items.length; i++) {
useEffect(() => { /* ... */ }, []) // Error!
}
// ✅ Correct:Conditional logic in Hook Internal Processing
const [extra, setExtra] = useState('')
useEffect(() => {
if (showExtra) { /* Conditional logic is placed in useEffect Inside */ }
}, [showExtra])
}
| Call Location | Valid | Reason |
|---|---|---|
| Top-level function components | ✅ | The order of the Hooks chain remains consistent with every render |
| Top-level custom hooks | ✅ | Custom hooks are essentially sub-call chains of function components |
| Inside if / for / while | ❌ | Changes in conditions cause inconsistent hook call counts, resulting in linked list misalignment |
| General utility functions | ❌ | Not in the React component context; no Hooks chain |
| Class Component Methods | ❌ | Class components do not have a Hooks chain mechanism |
| Inside a useEffect retorno de chamada | ❌ | The retorno de chamada is not at the top level of the component, so the call order is unpredictable |
(2) Rule 2: Call Hooks Only in Function Components or Custom Hooks
// ❌ Called from a regular function Hook
function getData() {
const [data, setData] = useState(null) // Error!
}
// ❌ Calling within a class component Hook
class MyClass extends React.Component {
render() {
const [count, setCount] = useState(0) // Error!
}
}
// ✅ Custom Hook Called in
function useCustomHook() {
const [data, setData] = useState(null) // ✅ Correct
}
(3) Why are these two rules so important?
graph TB
A[Component Rendering] --> B[Hook Linked List]
B --> C[Hook 1: useState<br/>Linked List Nodes 1]
B --> D[Hook 2: useEffect<br/>Linked List Nodes 2]
B --> E[Hook 3: useRef<br/>Linked List Nodes 3]
D -->|Conditional rendering causes skipping| F[Linked List Misalignment!]
F --> G[Hook 2 What I read was Hook 3 data]
G --> H[Bug! Data Chaos]
style F fill:#ff4d4f,color:#fff
style H fill:#ff4d4f,color:#fff
React relies on the order in which Hooks are called to synchronize state. If the order of calls differs with each render, the state becomes out of sync.
4. Pitfall 1: The Stale Closure Pitfall
The most common Hooks bug.
function Counter() {
const [count, setCount] = useState(0)
// ❌ Closure Pitfalls
useEffect(() => {
const timer = setInterval(() => {
console.log(count) // Always 0!
setCount(count + 1) // Set permanently to 1
}, 1000)
return () => clearInterval(timer)
}, []) // count is "locked" to first render
// ✅ Fix 1:Functional Updates
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1) // prev Always the latest value
}, 1000)
return () => clearInterval(timer)
}, [])
// ✅ Fix 2:Add Dependencies
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => clearInterval(timer)
}, [count]) // count Restart the timer when a change occurs
}
When it occurs: A value that was not declared in the dependencies array was read in useEffect, useCallback, or useMemo.
| Closure Pitfall Scenarios | Symptoms | Solutions |
|---|---|---|
| Reading state in setInterval | The value in the timer never changes | Functional updates setState(prev => ...) |
Reading state in useCallback |
Using an old value in the callback | Adding state to the dependency array or using a ref |
| Event handler execution is delayed | The state value in an asynchronous callback is outdated |
Use useRef to store the latest value |
| Missing useEffect dependencies | Variables used in the effect are not updated | Complete the dependency array |
| Set race condition after an asynchronous request | Old request overwrites new results during fast switching | Use the ignore flag or AbortController |
Troubleshooting Tip: All variables read in callbacks must be declared in the dependencies array. Alternatively, use functional updates.
5. Pitfall 2: useEffect Infinite Loop
(1) Reason 1: No array of dependencies
// ❌ Infinite Loop!
useEffect(() => {
fetch('/api/data').then(setData)
}) // No array dependencies → Executed on every render → setData Trigger Rendering → Infinite Loop
// ✅ Empty array of dependencies
useEffect(() => {
fetch('/api/data').then(setData)
}, [])
(2) Reason 2: Reference types in the dependency array
function App() {
const [count, setCount] = useState(0)
// ❌ Infinite Loop!
useEffect(() => {
console.log('useEffect Execute')
}, [{ count }]) // Create a new object on every render {} → Differences in Citation → Infinite Execution
// ✅ Correct:Using Primitive Types
useEffect(() => {
console.log('useEffect Execute')
}, [count]) // Basic Types,If the value remains unchanged, the action is not performed.
}
(3) Reason 3: Modifying dependencies inside useEffect
const [items, setItems] = useState([1, 2, 3])
// ❌ Infinite Loop!
useEffect(() => {
setItems([...items, 4]) // Edit items → Render Again → useEffect Execute again...
}, [items])
// ✅ Correct:Why is this change necessary? items?Generally, it is not necessary.
// If it's initialization,use useRef Flag indicating whether initialization has been completed
const initialized = useRef(false)
useEffect(() => {
if (!initialized.current) {
setItems([...items, 4])
initialized.current = true
}
}, [items])
(4) Reason 4: Functions as Dependencies
| Causes of Infinite Loops | Incorrect Code | Correct Code |
|---|---|---|
| No array dependencies | useEffect(fn) |
useEffect(fn, []) |
| Reference Types | useEffect(fn, [{ count }]) |
useEffect(fn, [count]) |
Modify dependencies in effect |
useEffect(() => setItems(...), [items]) |
Mark initialization with useRef |
| Functions as Dependencies | useEffect(fn, [handleClick]) |
useCallback Stable References |
| Object as a dependency | useEffect(fn, [config]) |
Extract a primitive type value or use useMemo |
// ❌ Each render creates a new function reference
function App() {
const handleClick = () => { /* ... */ }
useEffect(() => {
fetch('/api').then(setData)
}, [handleClick]) // handleClick A new reference every time → Infinite Execution
// ✅ use useCallback Stable Citation
const handleClick = useCallback(() => { /* ... */ }, [])
useEffect(() => {
fetch('/api').then(setData)
}, [handleClick]) // handleClick Citation unchanged,Will not be executed again
}
6. Pitfall 3: Asynchronous Updates with useState
function Counter() {
const [count, setCount] = useState(0)
function handleClick() {
// You thought it would +3,The result was only +1
setCount(count + 1)
setCount(count + 1)
setCount(count + 1) // All three times count + 1, but count = 0
console.log(count) // Output 0,No 3
}
// ✅ Functional Updates
function handleCorrectClick() {
setCount(prev => prev + 1) // prev = 0 → 1
setCount(prev => prev + 1) // prev = 1 → 2
setCount(prev => prev + 1) // prev = 2 → 3
// count Or 0(The rendered values for this pass),But the next time it's rendered, it will be 3
}
}
Key Concept: Updates to useState are asynchronous and batch-processed. During a single event handling cycle, multiple setCount instances are combined into a single render. Functional updates are used to ensure that every update is based on the latest values.
7. Pitfall 4: Using async in useEffect
// ❌ useEffect Cannot pass an async function directly
useEffect(async () => {
const data = await fetch('/api/data')
setData(await data.json())
}, [])
// Error! async returns Promise, useEffect expects return value of cleanup function (or undefined)
// ✅ Correct: Define async function internally
useEffect(() => {
async function fetchData() {
const res = await fetch('/api/data')
const data = await res.json()
setData(data)
}
fetchData() // Call async function internally
}, [])
8. Complete Example: 10 Common Bug Diagnosis Tables
// ============================================
// Example:10 A common Hooks Bug + Remediation Plan
// ============================================
// Bug 1:Conditions Hook
function Bug1({ show }) {
// if (show) const [x] = useState(0) // ❌
const [x] = useState(0) // ✅ Move to the top
}
// Bug 2:Closure Pitfalls
function Bug2() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
// setCount(count + 1) // ❌ Closure
setCount(prev => prev + 1) // ✅
}, 1000)
return () => clearInterval(id)
}, [])
}
// Bug 3:Infinite Loop(Dependency on Reference Types)
function Bug3() {
const [data, setData] = useState(null)
useEffect(() => {
fetch('/api').then(res => res.json()).then(setData)
}, []) // ✅ Empty array
// }, [data]) // ❌ data The change triggered another one useEffect
}
// Bug 4:Forgot to clean up
function Bug4() {
useEffect(() => {
const id = setInterval(() => console.log('tick'), 1000)
return () => clearInterval(id) // ✅ Must be cleaned up
}, [])
}
// Bug 5:Edit directly state
function Bug5() {
const [user, setUser] = useState({ name: 'Alice', age: 28 })
function updateAge() {
// user.age = 29 // ❌ Edit directly
setUser({ ...user, age: 29 }) // ✅ Create a New Object
}
}
// Bug 6:Not needed useMemo
function Bug6({ count }) {
// const doubled = useMemo(() => count * 2, [count]) // ❌ Unnecessary
const doubled = count * 2 // ✅ Simple calculations are not required useMemo
}
// Bug 7:effect Read from props But do not declare dependencies
function Bug7({ userId }) {
const [user, setUser] = useState(null)
useEffect(() => {
fetch('/api/users/' + userId).then(res => res.json()).then(setUser)
}, [userId]) // ✅ userId Dependencies must be added
// }, []) // ❌ userId Changes will not trigger a new request
}
// Bug 8:useMemo Return Function
function Bug8() {
// const fn = useMemo(() => { return () => console.log('hi') }, []) // ❌ Should use useCallback
const fn = useCallback(() => console.log('hi'), []) // ✅
}
// Bug 9:in render Side effects caused by the drug
function Bug9() {
const [data, setData] = useState(null)
// fetch('/api/data').then(setData) // ❌ A request is made for every render
useEffect(() => { fetch('/api/data').then(res => res.json()).then(setData) }, []) // ✅
}
// Bug 10:State dependency not fully declared
function Bug10({ items }) {
const [filter, setFilter] = useState('')
// const filtered = items.filter(i => i.includes(filter)) // ✅ in render Calculate directly in the middle
const filtered = useMemo(() => items.filter(i => i.includes(filter)), [items, filter])
// useMemo The correct dependencies must be in place.
}
9. Debugging Tips
(1) React DevTools Profiler
// in React DevTools 's Profiler In the tab:
// 1. Click the Record button
// 2. Triggering Actions in the App
// 3. Stop Recording
// 4. View the flame diagram:Each column is a component,Rendering Time by Height
// I see components that shouldn't be re-rendered being rendered → Inspection useCallback / React.memo
(2) why-did-you-render
npm install @welldone-software/why-did-you-render
// src/wdyr.js
import React from 'react'
if (process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render')
whyDidYouRender(React, {
trackAllPureComponents: true,
})
}
// The console will print:Why did this component re-render??Because which one Props It's changed?
(3) Debugging with useWhyDidYouUpdate
function useWhyDidYouUpdate(name, props) {
const previousProps = useRef(props)
useEffect(() => {
const allKeys = Object.keys({ ...previousProps.current, ...props })
const changes = {}
allKeys.forEach(key => {
if (previousProps.current[key] !== props[key]) {
changes[key] = {
from: previousProps.current[key],
to: props[key]
}
}
})
if (Object.keys(changes).length > 0) {
console.log(`[why-did-you-update] ${name}:`, changes)
}
previousProps.current = props
})
}
// Usage
function MyComponent(props) {
useWhyDidYouUpdate('MyComponent', props)
return <div>{props.name}</div>
}
▶ Example 2: Using a Custom Hook to Avoid Closure Pitfalls — useStableCallback
Output:
State: timeLeft (setter: setTimeLeft). useEffect manages side effects. Interval-based updates
// ============================================
// Example:useStableCallback——The Ultimate Solution to Closure Pitfalls
// Features:A callback function that always returns a reference to a stable value,However, the latest logic is executed when the function is called.
// ============================================
import { useRef, useCallback, useState, useEffect } from 'react'
// ---- Custom Hook:A Steady Pullback ----
function useStableCallback(callback) {
// use ref Store the latest callback
const callbackRef = useRef(callback)
// Update after each render ref Callbacks in
useEffect(() => {
callbackRef.current = callback
})
// Return a function that never changes
return useCallback((...args) => {
// Execute the latest version of the callback when it is called
return callbackRef.current(...args)
}, [])
}
// ---- Usage useStableCallback Avoiding Closure Pitfalls ----
function TimerWithStableCallback() {
const [count, setCount] = useState(0)
const [isRunning, setIsRunning] = useState(false)
// This callback function depends on count(Each render is a new function)
function onTick() {
console.log('Current Count:', count)
// If you use it directly here count,This can lead to a closure trap
// but useStableCallback ensures latest version is used.
}
// ✅ use useStableCallback Package,Citations are always reliable
const stableOnTick = useStableCallback(onTick)
useEffect(() => {
if (!isRunning) return
const timer = setInterval(() => {
// stableOnTick The reference will not change,However, the latest version is executed each time it is called. onTick
stableOnTick()
setCount(prev => prev + 1)
}, 1000)
return () => clearInterval(timer)
}, [isRunning, stableOnTick]) // stableOnTick Forever Unchanging,It will not cause an infinite loop
return (
<div style={{ maxWidth: '400px', margin: '0 auto', textAlign: 'center' }}>
<h2>⏱️ Solutions to Closure Pitfalls</h2>
<p style={{ fontSize: '48px', fontWeight: 'bold', margin: '20px 0' }}>{count}</p>
<button
onClick={() => setIsRunning(!isRunning)}
style={{
padding: '10px 24px',
backgroundColor: isRunning ? '#ff4d4f' : '#52c41a',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '16px'
}}
>
{isRunning ? '⏸ Pause' : '▶ Start'}
</button>
<p style={{ marginTop: '12px', color: '#666', fontSize: '13px' }}>
Open the console to view the logs——useStableCallback Ensure that the timer does not get stuck due to closures
</p>
</div>
)
}
// ---- Comparison:Do not use useStableCallback version ----
function TimerBroken() {
const [count, setCount] = useState(0)
const [isRunning, setIsRunning] = useState(false)
useEffect(() => {
if (!isRunning) return
// ❌ Use as is setInterval,count Locked in a closure
const timer = setInterval(() => {
setCount(count + 1) // Always 0 + 1 = 1
}, 1000)
return () => clearInterval(timer)
}, [isRunning]) // Missing count Dependency
// Remediation Plan:Update Using a Function
// setCount(prev => prev + 1) // ✅ Not necessary count Dependency
return (
<div style={{ maxWidth: '400px', margin: '0 auto', textAlign: 'center', marginTop: '40px' }}>
<h3>❌ The version with the issue(For comparison)</h3>
<p style={{ fontSize: '48px', fontWeight: 'bold', margin: '20px 0', color: '#ff4d4f' }}>{count}</p>
<button onClick={() => setIsRunning(!isRunning)}
style={{ padding: '10px 24px', backgroundColor: '#d9d9d9', color: '#333', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
Start(It gets stuck 1)
</button>
</div>
)
}
Output:
❌ Hooks inside conditions cause bugs. ✅ Always call hooks at top level. Rule: Hooks must be called in same order every render.
▶ Example 3: Pitfalls and Fixes for Batch Updates with useState
Output:
... ('Current Count:', count)
function ShoppingCart() {
const [items, setItems] = useState([])
const [total, setTotal] = useState(0)
function addItem(item) {
setItems([...items, item])
setTotal(items.reduce((sum, i) => sum + i.price, 0) + item.price)
}
function addItemFixed(item) {
setItems(prev => [...prev, item])
setTotal(prev => prev + item.price)
}
function addMultiple() {
setItems(prev => [...prev, { id: 1, name: 'Book', price: 29 }])
setItems(prev => [...prev, { id: 2, name: 'Pen', price: 5 }])
setTotal(prev => prev + 34)
}
return (
<div>
<h3>Shopping Cart</h3>
<ul>
{items.map((item, idx) => (
<li key={idx}>{item.name} - ${item.price}</li>
))}
</ul>
<p>Total: ${total}</p>
<button onClick={() => addItemFixed({ id: 3, name: 'Notebook', price: 12 })}>
Add Notebook
</button>
<button onClick={addMultiple}>Add Book + Pen</button>
</div>
)
}
Output:
Renders: Shopping Cart + Total: ${total}
▶ Example 4: Race Conditions in Asynchronous Requests with useEffect
Output:
Displays: "Shopping Cart". State: items (setter: setItems), total (setter: setTotal). Buttons: addItemFixed({ id: 3, name: 'Notebook', price: 12 })}> Add Notebook, Add Book + Pen. List: {item.name} - ${item.price}
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
let ignore = false
setLoading(true)
async function fetchUser() {
try {
const res = await fetch(`/api/users/${userId}`)
const data = await res.json()
if (!ignore) {
setUser(data)
setLoading(false)
}
} catch (err) {
if (!ignore) setLoading(false)
}
}
fetchUser()
return () => { ignore = true }
}, [userId])
if (loading) return <p>Loading...</p>
if (!user) return <p>No user found</p>
return (
<div>
<h3>{user.name}</h3>
<p>Email: {user.email}</p>
<p>Role: {user.role}</p>
</div>
)
}
Output:
try { await fetchData(); } catch (err) { setError(err.message); } → "Failed to load data" shown. Error handled, app stays running.
▶ Example 5: Using useReducer Instead of Multiple useState Calls to Avoid State Inconsistencies
Output:
Displays: "Loading...". State: user (setter: setUser), loading (setter: setLoading). useEffect manages side effects. Async data fetching/loading states
const initialState = { count: 0, step: 1, history: [] }
function reducer(state, action) {
switch (action.type) {
case 'increment':
return {
...state,
count: state.count + state.step,
history: [...state.history, `+${state.step}`],
}
case 'decrement':
return {
...state,
count: state.count - state.step,
history: [...state.history, `-${state.step}`],
}
case 'setStep':
return { ...state, step: action.payload }
case 'reset':
return initialState
default:
return state
}
}
function CounterWithHistory() {
const [state, dispatch] = useReducer(reducer, initialState)
return (
<div>
<h3>Count: {state.count}</h3>
<label>
Step:
<input
type="number"
value={state.step}
onChange={e => dispatch({ type: 'setStep', payload: Number(e.target.value) })}
/>
</label>
<button onClick={() => dispatch({ type: 'increment' })}>+{state.step}</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-{state.step}</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
<p>History: {state.history.join(', ')}</p>
</div>
)
}
Output:
Renders: Count: {state.count} + History: {state.history.join(', ')}
❓ FAQ
setState?state object directly (without creating a new reference); ② The state value didn’t change (React uses Object.is to compare values; identical values do not trigger a render); ③ You forgot to declare the updated variable in the useEffect’s dependencies array; ④ You read an old closure value in an asynchronous callback.useEffect?useEffect(fn) → Executes after every render (on mount + every update); An empty dependencies array useEffect(fn, []) → Executes only once during mounting; Non-empty dependencies useEffect(fn, [a, b]) → Executes during mounting + executes when a or b changes. The most common source of bugs: specifying an empty array but using state or props inside the effect (closure trap).📖 Summary
- Two Ironclad Rules: Call Hooks only at the top level, and call Hooks only within function components or custom Hooks
- Closure Pitfalls: Variables accessed within
useEffectoruseCallbackmust be declared in the dependencies array or updated using a function. - Infinite Loop: Check whether the dependency array is correct, whether the reference types are stable, and whether dependencies have been modified within the effect
- Functional Updates:
setCount(prev => prev + 1)Resolves closure pitfalls and batch update issues - Install
eslint-plugin-react-hooks; do not manually disable it - Use the React DevTools Profiler to identify performance bottlenecks
📝 Exercises
-
Basic Problem (Difficulty: ⭐): Find and fix the bug in the code below:
▶ Try it YourselfJSXfunction Timer() { const [time, setTime] = useState(0) useEffect(() => { const id = setInterval(() => { setTime(time + 1) }, 1000) return () => clearInterval(id) }, []) return <p>{time}s</p> } -
Advanced Problem (Difficulty ⭐⭐): Find and fix the bugs in the code below (at least 3):
▶ Try it YourselfJSXfunction SearchPage() { const [query, setQuery] = useState('') const [results, setResults] = useState([]) useEffect(async () => { const data = await fetch('/api/search?q=' + query) setResults(data) }, []) return <div> <input value={query} onChange={e => setQuery(e.target.value)} /> {results.map(r => <p key={r.id}>{r.name}</p>)} </div> } -
Challenge (Difficulty: ⭐⭐⭐): Implement a
useStableCallbackHook that accepts a retorno de chamada function and always returns a reference-stable function, but executes the latest retorno de chamada when invoked (the ultimate solution to the fechamento trap).