React: Hooks 规则与常见陷阱

最后更新:2026-08-26

1. 你将学到



2. 一个调试噩梦的故事

(1) 痛点:一个 useEffect 调试了 3 小时

Alice 写了一个倒计时组件:

▶ 示例:Hooks规则演示

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(() => {
      // ❌ 闭包陷阱:timeLeft 永远是初始值
      setTimeLeft(timeLeft - 1)
    }, 1000)

    return () => clearInterval(timer)
  }, [])  // 想只在挂载时启动

  return <p>剩余:{timeLeft} 秒</p>
}
▶ 试一试

Bug:倒计时永远停在初始值 -1。因为 setInterval 回调里捕获的 timeLeft 是首次渲染时的值(闭包陷阱)。

(2) 修复方案

JSX
// ✅ 修复 1:函数式更新
useEffect(() => {
  const timer = setInterval(() => {
    setTimeLeft(prev => Math.max(0, prev - 1))  // prev 永远是最新值
  }, 1000)
  return () => clearInterval(timer)
}, [])

// ✅ 修复 2:或者把 targetDate 加入依赖数组
useEffect(() => {
  const timer = setInterval(() => {
    setTimeLeft(prev => Math.max(0, prev - 1))
  }, 1000)
  return () => clearInterval(timer)
}, [targetDate])  // targetDate 变化时重启定时器
▶ 试一试

教训:永远不要在 useEffect 中直接读取 state 来更新自身。用函数式更新 setState(prev => prev + 1)



3. Hooks 的两条铁律

(1) 规则 1:只在最顶层调用 Hooks

不要在循环、条件语句或嵌套函数中调用 Hooks:

JSX
function BadComponent({ showExtra }) {
  const [name, setName] = useState('')  // ✅ 顶层

  // ❌ 规则违反:条件中调用 Hook
  if (showExtra) {
    const [extra, setExtra] = useState('')  // 报错!
    useEffect(() => { /* ... */ }, [])
  }

  // ❌ 规则违反:循环中调用 Hook
  for (let i = 0; i < items.length; i++) {
    useEffect(() => { /* ... */ }, [])  // 报错!
  }

  // ✅ 正确:条件逻辑在 Hook 内部处理
  const [extra, setExtra] = useState('')
  useEffect(() => {
    if (showExtra) { /* 条件逻辑放在 useEffect 内部 */ }
  }, [showExtra])
}
▶ 试一试
调用位置 是否合法 原因
函数组件顶层 Hooks 链表顺序每次渲染一致
自定义 Hook 顶层 自定义 Hook 本质是函数组件的子调用链
if / for / while 内部 条件变化导致 Hooks 调用次数不一致,链表错位
普通工具函数 不在 React 组件上下文中,无 Hooks 链表
类组件方法 类组件没有 Hooks 链表机制
useEffect 回调内部 回调不是组件顶层,调用顺序不可预测

(2) 规则 2:只在函数组件或自定义 Hook 中调用 Hooks

JSX
// ❌ 普通函数中调用 Hook
function getData() {
  const [data, setData] = useState(null)  // 报错!
}

// ❌ 类组件中调用 Hook
class MyClass extends React.Component {
  render() {
    const [count, setCount] = useState(0)  // 报错!
  }
}

// ✅ 自定义 Hook 中调用
function useCustomHook() {
  const [data, setData] = useState(null)  // ✅ 正确
}
▶ 试一试

(3) 为什么这两个规则如此重要?

100%
graph TB
    A[组件渲染] --> B[Hook 链表]
    B --> C[Hook 1: useState<br/>链表节点 1]
    B --> D[Hook 2: useEffect<br/>链表节点 2]
    B --> E[Hook 3: useRef<br/>链表节点 3]
    D -->|条件渲染导致跳过| F[链表错位!]
    F --> G[Hook 2 读到的是 Hook 3 的数据]
    G --> H[Bug! 数据混乱]
    
    style F fill:#ff4d4f,color:#fff
    style H fill:#ff4d4f,color:#fff

React 依赖 Hooks 的调用顺序来匹配状态。如果每次渲染调用顺序不同,状态就乱了。



4. 陷阱 1:闭包陷阱(Stale Closure)

最常见的 Hooks Bug。

JSX
function Counter() {
  const [count, setCount] = useState(0)

  // ❌ 闭包陷阱
  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count)  // 永远是 0!
      setCount(count + 1)  // 永远设置成 1
    }, 1000)
    return () => clearInterval(timer)
  }, [])  // count 被"锁"在首次渲染

  // ✅ 修复 1:函数式更新
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(prev => prev + 1)  // prev 永远是最新值
    }, 1000)
    return () => clearInterval(timer)
  }, [])

  // ✅ 修复 2:加入依赖
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(count + 1)
    }, 1000)
    return () => clearInterval(timer)
  }, [count])  // count 变化时重启定时器
}
▶ 试一试

什么时候发生:在 useEffectuseCallbackuseMemo 中读取了一个没有在依赖数组中声明的值。

闭包陷阱场景 症状 修复方式
setInterval 中读 state 定时器中的值永远不变 函数式更新 setState(prev => ...)
useCallback 中读 state 回调中使用旧值 将 state 加入依赖数组或用 ref
事件处理函数延迟执行 异步回调中 state 是旧值 用 useRef 存储最新值
useEffect 依赖遗漏 effect 中使用的变量不更新 补全依赖数组
异步请求后 set 竞态 快速切换时旧请求覆盖新结果 用 ignore flag 或 AbortController

修复口诀所有在回调中读取的变量,都要在依赖数组中声明。或者用函数式更新。



5. 陷阱 2:useEffect 无限循环

(1) 原因 1:没有依赖数组

JSX
// ❌ 无限循环!
useEffect(() => {
  fetch('/api/data').then(setData)
})  // 没有依赖数组 → 每次渲染都执行 → setData 触发渲染 → 无限循环

// ✅ 空依赖数组
useEffect(() => {
  fetch('/api/data').then(setData)
}, [])
▶ 试一试

(2) 原因 2:依赖数组中的引用类型

JSX
function App() {
  const [count, setCount] = useState(0)

  // ❌ 无限循环!
  useEffect(() => {
    console.log('useEffect 执行')
  }, [{ count }])  // 每次渲染都创建新对象 {} → 引用不同 → 无限执行

  // ✅ 正确:用基本类型
  useEffect(() => {
    console.log('useEffect 执行')
  }, [count])  // 基本类型,值不变就不执行
}
▶ 试一试

(3) 原因 3:在 useEffect 里修改依赖

JSX
const [items, setItems] = useState([1, 2, 3])

// ❌ 无限循环!
useEffect(() => {
  setItems([...items, 4])  // 修改 items → 重新渲染 → useEffect 又执行...
}, [items])

// ✅ 正确:为什么需要修改 items?一般不需要
// 如果是初始化,用 useRef 标记是否已初始化
const initialized = useRef(false)
useEffect(() => {
  if (!initialized.current) {
    setItems([...items, 4])
    initialized.current = true
  }
}, [items])
▶ 试一试

(4) 原因 4:函数作为依赖

无限循环原因 错误写法 正确写法
没有依赖数组 useEffect(fn) useEffect(fn, [])
依赖引用类型 useEffect(fn, [{ count }]) useEffect(fn, [count])
effect 中修改依赖 useEffect(() => setItems(...), [items]) useRef 标记初始化
函数作为依赖 useEffect(fn, [handleClick]) useCallback 稳定引用
对象作为依赖 useEffect(fn, [config]) 提取基本类型值或用 useMemo
JSX
// ❌ 每次渲染都是新的函数引用
function App() {
  const handleClick = () => { /* ... */ }

  useEffect(() => {
    fetch('/api').then(setData)
  }, [handleClick])  // handleClick 每次都是新引用 → 无限执行

  // ✅ 用 useCallback 稳定引用
  const handleClick = useCallback(() => { /* ... */ }, [])

  useEffect(() => {
    fetch('/api').then(setData)
  }, [handleClick])  // handleClick 引用不变,不会重复执行
}
▶ 试一试

6. 陷阱 3:useState 异步更新

JSX
function Counter() {
  const [count, setCount] = useState(0)

  function handleClick() {
    // 你以为会 +3,结果只 +1
    setCount(count + 1)
    setCount(count + 1)
    setCount(count + 1)  // 三次都是 count + 1,而 count = 0
    console.log(count)   // 输出 0,不是 3
  }

  // ✅ 函数式更新
  function handleCorrectClick() {
    setCount(prev => prev + 1)  // prev = 0 → 1
    setCount(prev => prev + 1)  // prev = 1 → 2
    setCount(prev => prev + 1)  // prev = 2 → 3
    // count 还是 0(本次渲染的值),但下次渲染时是 3
  }
}
▶ 试一试

核心理解useState 的更新是异步批量处理的。在一次事件处理中,多个 setCount 会合并成一次渲染。用函数式更新来保证每次更新都基于最新值。



7. 陷阱 4:在 useEffect 中使用 async

JSX
// ❌ useEffect 不能直接传 async 函数
useEffect(async () => {
  const data = await fetch('/api/data')
  setData(await data.json())
}, [])
// 报错!async 函数返回 Promise,useEffect 期望返回清理函数(或 undefined)

// ✅ 正确:在内部定义 async 函数
useEffect(() => {
  async function fetchData() {
    const res = await fetch('/api/data')
    const data = await res.json()
    setData(data)
  }
  fetchData()  // 调用内部 async 函数
}, [])
▶ 试一试

8. 完整示例:10 个常见 Bug 诊断表

JSX
// ============================================
// 示例:10 个常见 Hooks Bug + 修复方案
// ============================================

// Bug 1:条件 Hook
function Bug1({ show }) {
  // if (show) const [x] = useState(0)  // ❌
  const [x] = useState(0)  // ✅ 移到顶层
}

// Bug 2:闭包陷阱
function Bug2() {
  const [count, setCount] = useState(0)
  useEffect(() => {
    const id = setInterval(() => {
      // setCount(count + 1)  // ❌ 闭包
      setCount(prev => prev + 1)  // ✅
    }, 1000)
    return () => clearInterval(id)
  }, [])
}

// Bug 3:无限循环(依赖引用类型)
function Bug3() {
  const [data, setData] = useState(null)
  useEffect(() => {
    fetch('/api').then(res => res.json()).then(setData)
  }, [])  // ✅ 空数组
  // }, [data])  // ❌ data 变化又触发 useEffect
}

// Bug 4:忘记清理
function Bug4() {
  useEffect(() => {
    const id = setInterval(() => console.log('tick'), 1000)
    return () => clearInterval(id)  // ✅ 必须清理
  }, [])
}

// Bug 5:直接修改 state
function Bug5() {
  const [user, setUser] = useState({ name: 'Alice', age: 28 })
  function updateAge() {
    // user.age = 29  // ❌ 直接修改
    setUser({ ...user, age: 29 })  // ✅ 创建新对象
  }
}

// Bug 6:不需要的 useMemo
function Bug6({ count }) {
  // const doubled = useMemo(() => count * 2, [count])  // ❌ 多此一举
  const doubled = count * 2  // ✅ 简单计算不需要 useMemo
}

// Bug 7:effect 中读取 props 但不声明依赖
function Bug7({ userId }) {
  const [user, setUser] = useState(null)
  useEffect(() => {
    fetch('/api/users/' + userId).then(res => res.json()).then(setUser)
  }, [userId])  // ✅ userId 必须加入依赖
  // }, [])  // ❌ userId 变化不会重新请求
}

// Bug 8:useMemo 返回函数
function Bug8() {
  // const fn = useMemo(() => { return () => console.log('hi') }, [])  // ❌ 应该用 useCallback
  const fn = useCallback(() => console.log('hi'), [])  // ✅
}

// Bug 9:在 render 中发起副作用
function Bug9() {
  const [data, setData] = useState(null)
  // fetch('/api/data').then(setData)  // ❌ 每次渲染都请求
  useEffect(() => { fetch('/api/data').then(res => res.json()).then(setData) }, [])  // ✅
}

// Bug 10:状态依赖未完整声明
function Bug10({ items }) {
  const [filter, setFilter] = useState('')
  // const filtered = items.filter(i => i.includes(filter))  // ✅ 在 render 中直接计算
  const filtered = useMemo(() => items.filter(i => i.includes(filter)), [items, filter])
  // useMemo 必须有正确的依赖
}


9. 调试技巧

(1) React DevTools Profiler

JSX
// 在 React DevTools 的 Profiler 标签页中:
// 1. 点击录制按钮
// 2. 在应用中触发操作
// 3. 停止录制
// 4. 查看火焰图:每一列是一个组件,高度代表渲染时间
// 看到不该重新渲染的组件渲染了 → 检查 useCallback / React.memo
▶ 试一试

(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,
  })
}
// 控制台会打印:为什么这个组件重新渲染了?因为哪个 Props 变了?
▶ 试一试

(3) 使用 useWhyDidYouUpdate 调试

JSX
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
  })
}

// 使用
function MyComponent(props) {
  useWhyDidYouUpdate('MyComponent', props)
  return <div>{props.name}</div>
}
▶ 试一试

▶ 示例 2:自定义 Hook 解决闭包陷阱 —— useStableCallback

JSX 📖 仅展示
// ============================================
// 示例:useStableCallback——解决闭包陷阱的终极方案
// 功能:始终返回一个引用稳定的回调函数,但调用时执行最新逻辑
// ============================================

import { useRef, useCallback, useState, useEffect } from 'react'

// ---- 自定义 Hook:稳定的回调 ----
function useStableCallback(callback) {
  // 用 ref 存储最新的回调
  const callbackRef = useRef(callback)

  // 每次渲染后更新 ref 中的回调
  useEffect(() => {
    callbackRef.current = callback
  })

  // 返回一个永远不变的函数
  return useCallback((...args) => {
    // 调用时执行最新版本的回调
    return callbackRef.current(...args)
  }, [])
}

// ---- 使用 useStableCallback 解决闭包陷阱 ----
function TimerWithStableCallback() {
  const [count, setCount] = useState(0)
  const [isRunning, setIsRunning] = useState(false)

  // 这个回调函数依赖 count(每次渲染都是新函数)
  function onTick() {
    console.log('当前计数:', count)
    // 这里如果直接使用 count,会形成闭包陷阱
    // 但 useStableCallback 会保证调用的是最新版本
  }

  // ✅ 用 useStableCallback 包裹,引用永远稳定
  const stableOnTick = useStableCallback(onTick)

  useEffect(() => {
    if (!isRunning) return

    const timer = setInterval(() => {
      // stableOnTick 引用不会变,但每次调用时执行的是最新的 onTick
      stableOnTick()
      setCount(prev => prev + 1)
    }, 1000)

    return () => clearInterval(timer)
  }, [isRunning, stableOnTick])  // stableOnTick 永远不变,不会导致死循环

  return (
    <div style={{ maxWidth: '400px', margin: '0 auto', textAlign: 'center' }}>
      <h2>⏱️ 闭包陷阱解决方案</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 ? '⏸ 暂停' : '▶ 开始'}
      </button>
      <p style={{ marginTop: '12px', color: '#666', fontSize: '13px' }}>
        打开控制台查看日志——useStableCallback 确保定时器不会因为闭包而卡住
      </p>
    </div>
  )
}

// ---- 对比:不使用 useStableCallback 的版本 ----
function TimerBroken() {
  const [count, setCount] = useState(0)
  const [isRunning, setIsRunning] = useState(false)

  useEffect(() => {
    if (!isRunning) return
    // ❌ 直接使用 setInterval,count 被锁在闭包中
    const timer = setInterval(() => {
      setCount(count + 1)  // 永远是 0 + 1 = 1
    }, 1000)
    return () => clearInterval(timer)
  }, [isRunning])  // 缺少 count 依赖

  // 修复方案:用函数式更新
  // setCount(prev => prev + 1)  // ✅ 不需要 count 依赖

  return (
    <div style={{ maxWidth: '400px', margin: '0 auto', textAlign: 'center', marginTop: '40px' }}>
      <h3>❌ 有问题的版本(对比用)</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' }}>
        开始(会卡在 1)
      </button>
    </div>
  )
}
逻辑代码 70 行(超过 40 行限制,仅展示)

▶ 示例 3:useState 批量更新陷阱与修复

JSX
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>
  )
}
▶ 试一试

▶ 示例 4: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>
  )
}
▶ 试一试

▶ 示例 5:useReducer 替代多个 useState 避免状态不一致

JSX 📖 仅展示
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>
  )
}
逻辑代码 43 行(超过 40 行限制,仅展示)

❓ 常见问题

Q 为什么有时候 setState 后页面没有更新?
A 可能的原因:① 直接修改了 state 对象(没有创建新引用);② 状态值没变(React 用 Object.is 比较,相同值不触发渲染);③ 在 useEffect 的依赖数组里忘了声明更新的变量;④ 在异步回调中读取了旧的闭包值。
Q useEffect 的依赖数组写空和不写有什么区别?
A 不写依赖数组 useEffect(fn) → 每次渲染后都执行(挂载 + 每次更新);空依赖数组 useEffect(fn, []) → 只在挂载时执行一次;有依赖 useEffect(fn, [a, b]) → 挂载时执行 + a 或 b 变化时执行。最常见的 Bug 来源:写了空数组但 effect 内部用了 state/props(闭包陷阱)。

📖 小节


📝 作业

  1. 基础题(难度⭐):找出下面代码中的 Bug 并修复:

    JSX
    function Timer() {
      const [time, setTime] = useState(0)
      useEffect(() => {
        const id = setInterval(() => {
          setTime(time + 1)
        }, 1000)
        return () => clearInterval(id)
      }, [])
      return <p>{time}s</p>
    }
    
    ▶ 试一试
  2. 进阶题(难度⭐⭐):找出下面代码中的 Bug 并修复(至少 3 个):

    JSX
    function 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>
    }
    
    ▶ 试一试
  3. 挑战题(难度⭐⭐⭐):实现一个 useStableCallback Hook,接收一个回调函数,始终返回一个引用稳定的函数,但调用时执行的是最新的回调(解决闭包陷阱的终极方案)。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

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

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