Hooks 时序与闭包:从误区到实战修复(useState / useEffect / useReducer)

66 阅读5分钟

前言:为什么「看起来没问题」的 Hooks 代码会失控

在使用 Hooks 的过程中,很多问题并不是 API 用错,而是代码的执行时机与我们直觉中的“当前状态”不一致。UI 已经更新,异步回调却读到旧值;effect 只想跑一次,却不得不不断重建;状态逻辑写得很完整,行为却依然反常——这些现象背后,往往同时涉及 闭包、更新时序、副作用生命周期 等多个因素。

React 的函数组件并不是“始终运行在现在”,而是在不同时间点执行不同版本的函数与回调。只要代码跨越了渲染边界(异步、定时器、订阅、effect),就需要明确:这段逻辑拿到的到底是哪一次渲染的数据。本文将围绕 useStateuseEffectuseReducer 的常见误区,系统梳理这些“看不见的时序问题”,并给出在真实项目中可复用、可解释的修复策略。

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 代码就会稳得多。