React: Hooks Rules and Common Pitfalls

Last updated: 2026-10-26

1. What You'll Learn



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:

TEXT
State: timeLeft. side effects via useEffect. interval updates
JSX
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>
}
▶ Try it Yourself

Output:

TEXT
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

JSX
// ✅ 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
▶ Try it Yourself

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:

JSX
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])
}
▶ Try it Yourself
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

JSX
// ❌ 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
}
▶ Try it Yourself

(3) Why are these two rules so important?

100%
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.

JSX
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
}
▶ Try it Yourself

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

JSX
// ❌ 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)
}, [])
▶ Try it Yourself

(2) Reason 2: Reference types in the dependency array

JSX
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.
}
▶ Try it Yourself

(3) Reason 3: Modifying dependencies inside useEffect

JSX
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])
▶ Try it Yourself

(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
JSX
// ❌ 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
}
▶ Try it Yourself

6. Pitfall 3: Asynchronous Updates with useState

JSX
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
  }
}
▶ Try it Yourself

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

JSX
// ❌ 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
}, [])
▶ Try it Yourself

8. Complete Example: 10 Common Bug Diagnosis Tables

JSX
// ============================================
// 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.
}
▶ Try it Yourself

9. Debugging Tips

(1) React DevTools Profiler

JSX
// 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
▶ Try it Yourself

(2) why-did-you-render

BASH
npm install @welldone-software/why-did-you-render
JSX
// 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?
▶ Try it Yourself

(3) Debugging with useWhyDidYouUpdate

▶ Try it Yourself

Output:

TEXT
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)
▶ Try it Yourself

Output:

TEXT
❌ Hooks inside conditions cause bugs. ✅ Always call hooks at top level. Rule: Hooks must be called in same order every render.

Output:

TEXT
... ('Current Count:', count)

▶ Example 3: Pitfalls and Fixes for Batch Updates with useState

▶ Try it Yourself

Output:

TEXT
Renders: Shopping Cart + Total: ${total}

Output:

TEXT
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

JSX
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>
  )
}
▶ Try it Yourself

Output:

TEXT
try { await fetchData(); } catch (err) { setError(err.message); } → "Failed to load data" shown. Error handled, app stays running.

Output:

TEXT
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:

TEXT
Renders: Count: {state.count} + History: {state.history.join(', ')}

❓ FAQ

Q Why doesn’t the page update sometimes after calling setState?
A Possible reasons: ① You modified the 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.
Q What’s the difference between setting the dependencies array to empty and leaving it empty in useEffect?
A Leaving the dependencies array empty 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


📝 Exercises

  1. Basic Problem (Difficulty: ⭐): Find and fix the bug in the code below:

    ▶ Try it Yourself
  2. Advanced Problem (Difficulty ⭐⭐): Find and fix the bugs in the code below (at least 3):

    ▶ Try it Yourself
  3. Challenge (Difficulty: ⭐⭐⭐): Implement a useStableCallback Hook 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).

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏