React: Hooks Rules and Common Pitfalls
Last updated: 2026-10-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
▶ Try it Yourself
Output:
State: timeLeft (setter: setTimeLeft). useEffect manages side effects. Interval-based updates
▶ Example 2: Using a Custom Hook to Avoid Closure Pitfalls — useStableCallback
▶ Expand runnable example (70 lines)
Output:
❌ Hooks inside conditions cause bugs. ✅ Always call hooks at top level. Rule: Hooks must be called in same order every render.
Output:
... ('Current Count:', count)
▶ Example 3: Pitfalls and Fixes for Batch Updates with useState
▶ Try it YourselfOutput:
Renders: Shopping Cart + Total: ${total}
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}
▶ Example 4: Race Conditions in Asynchronous Requests with useEffect
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.
Output:
Displays: "Loading...". State: user (setter: setUser), loading (setter: setLoading). useEffect manages side effects. Async data fetching/loading states
▶ Example 5: Using useReducer Instead of Multiple useState Calls to Avoid State Inconsistencies
▶ Try it Yourself
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 Yourself -
Advanced Problem (Difficulty ⭐⭐): Find and fix the bugs in the code below (at least 3):
▶ Try it Yourself -
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).