前言:为什么「看起来没问题」的 Hooks 代码会失控
在使用 Hooks 的过程中,很多问题并不是 API 用错,而是代码的执行时机与我们直觉中的“当前状态”不一致。UI 已经更新,异步回调却读到旧值;effect 只想跑一次,却不得不不断重建;状态逻辑写得很完整,行为却依然反常——这些现象背后,往往同时涉及 闭包、更新时序、副作用生命周期 等多个因素。
React 的函数组件并不是“始终运行在现在”,而是在不同时间点执行不同版本的函数与回调。只要代码跨越了渲染边界(异步、定时器、订阅、effect),就需要明确:这段逻辑拿到的到底是哪一次渲染的数据。本文将围绕 useState、useEffect、useReducer 的常见误区,系统梳理这些“看不见的时序问题”,并给出在真实项目中可复用、可解释的修复策略。
useState 的常见错误:异步初始化与闭包更新
错误 1:在 useState 初始器中执行异步逻辑
许多人误以为可以把异步逻辑直接放到 useState 的初始器里做“延迟初始化”。这是错误的:如果你传入 async 函数,useState 会接收到一个 Promise,而不是最终值。
错误写法:
// ❌ 误用:async 函数会返回 Promise,而不是最终值
const [num, setNum] = useState(async () => {
const res = await getData()
return res
})
正确示例:把异步逻辑放在 effect 中
const [num, setNum] = useState(0)
useEffect(() => {
// ✅ 异步逻辑放在 effect 中
getData().then(res => {
setNum(res)
})
}, [])
这样写既符合 Hooks 的使用方式,也能避免一开始就把未完成的异步结果当成状态,从而引出后续的更新问题。
错误 2:在异步回调中直接使用 state 更新
当更新依赖于之前的 state,首选函数式更新。
错误写法:
setTimeout(() => {
setCount(count + 1) // 捕获了创建时的 count
}, 1000)
推荐写法:
setTimeout(() => {
setCount(prev => prev + 1)
}, 1000)
函数式更新把“读取当前 state 并返回新 state”的动作交给 React,回调收到的 prev 总是最新值,从而避免闭包读取到旧变量。几乎所有基于先前 state 的异步更新都能用这招解决。
错误 3:在长期存在的回调中读取 state
对于“创建一次、长期存在”的回调(例如只在挂载时创建的 setInterval 或外部订阅),使用 useRef 保存最新值,回调从 ref.current 读取,是既高效又安全的做法。
//示例:定时器读取最新 count
import { useEffect, useRef, useState } from 'react'
function TimerRef() {
const [count, setCount] = useState(0)
const countRef = useRef(count)
// 同步更新 ref,保证 ref.current 始终最新
useEffect(() => {
countRef.current = count
}, [count])
// interval 只创建一次,但读取的是 ref.current(最新)
useEffect(() => {
const id = setInterval(() => {
console.log('count via ref:', countRef.current)
}, 1000)
return () => clearInterval(id)
}, [])
}
把 ref 想成一张随时可擦写的便签:写入不触发渲染,但闭包能随时读取到最新内容。这个技巧常用于需要高效且稳定的长期回调场景。
错误 4:为了读取最新 state 被迫膨胀 effect 依赖
React 在新版本中引入 useEffectEvent(Effect Events),用于把“非响应性逻辑”从 effect 中抽离,创建“稳定的事件处理器”,这些处理器能够安全读取最新 props/state,而不把它们全部列入 effect 依赖数组。
import { useEffect, useEffectEvent } from 'react'
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme)
})
useEffect(() => {
const socket = connect(roomId)
socket.on('connected', onConnected)
return () => socket.disconnect()
}, [roomId, onConnected])
}
如果项目可升级且团队已评估兼容性,
useEffectEvent在某些场景下确实能简化依赖管理;否则useRef+ 函数式更新 仍是最通用、安全的选择。
useReducer 的 dispatch 陷阱(与 useEffect 搭配时常见的问题)
useReducer 适合管理复杂状态,但在和 useEffect、异步回调一起使用时,容易因为对 dispatch 的理解偏差而引入问题。需要先明确两点:
dispatch 的引用是稳定的,问题通常不在 dispatch 本身,而在 effect / 回调中捕获的 state 是否过期 ;
dispatch 不是 setState,不能默认支持函数式更新语法。
下面按常见场景拆解。
错误 1:误以为 dispatch 支持函数式更新
错误写法:
const [state, dispatch] = useReducer(reducer, { count: 0 })
function incrementWrong() {
// 错把 dispatch 当成 setState(prev => ...)
dispatch(prev => ({ ...prev, count: prev.count + 1 }))
}
推荐写法:
function increment() {
dispatch({ type: 'increment' })
}
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + 1 }
default:
return state
}
}
错误 2:只运行一次的 effect 中依据被捕获的 state 去 dispatch(旧值判断)
错误写法:
// ❌ interval 只创建一次,但闭包里的 state 可能是初始值
useEffect(() => {
const id = setInterval(() => {
if (state.count < 5) {
dispatch({ type: 'tick' })
}
}, 1000)
return () => clearInterval(id)
}, []) // <- state 未列入 deps,闭包中读取到的可能是旧值
后果:判断 state.count < 5 始终基于 effect 创建时的快照,逻辑可能永远满足或永远不满足。
推荐写法 A(直接):把需要的 state 列为依赖
useEffect(() => {
const id = setInterval(() => {
if (state.count < 5) dispatch({ type: 'tick' })
}, 1000)
return () => clearInterval(id)
}, [state.count])
推荐写法 B(高效,推荐):用 useRef 保存最新 state(复用上文 useRef 原则)
const stateRef = useRef(state)
useEffect(() => { stateRef.current = state }, [state])
useEffect(() => {
const id = setInterval(() => {
if (stateRef.current.count < 5) {
dispatch({ type: 'tick' })
}
}, 1000)
return () => clearInterval(id)
}, []) // interval 只创建一次,读取的是最新的 stateRef.current
这个模式避免频繁重建订阅,同时保证回调读取最新状态;在 useReducer 场景下尤其常用。
错误 3:异步效果完成后无判断就 dispatch(组件已卸载或场景已变)
错误写法:
useEffect(() => {
fetchData().then(data => {
dispatch({ type: 'dataLoaded', payload: data })
})
}, [])
推荐写法:在 effect 中做挂载检测或使用可取消的 API
useEffect(() => {
let mounted = true
fetchData().then(data => {
if (mounted) dispatch({ type: 'dataLoaded', payload: data })
})
return () => { mounted = false }
}, [])
结语:把“快照”看清楚,就能写对 Hooks
理解 Hooks 的关键不在于记住每个 API 的签名,而在于弄清「这段代码在什么时候、看到的是哪一次渲染的数据」。把副作用放到 useEffect、遇到基于旧值的更新用函数式更新、长期回调用 useRef 保存最新值——这三招能解决绝大多数问题。对复杂场景,考虑把逻辑移入 reducer 或使用新版 API(如 useEffectEvent)以保持语义清晰。
把这些习惯写进代码库与测试里,你的 Hooks 代码就会稳得多。