首页 React Hooks
React Hooks useState useEffect 避坑指南

React Hooks使用心得:useState与useEffect的正确打开方式

拆解高频误用场景,理清底层运行逻辑,给出可直接落地的编码规范,帮助避开90%的Hooks暗坑

凡尘
2024年12月 约 22 分钟阅读 9.6k

自React 16.8正式推出Hooks之后,函数组件彻底告别了"无状态组件"的时代,我们不再依赖class组件的生命周期、this实例,就可以实现状态管理、副作用处理、逻辑复用。绝大多数业务开发都迁移到函数组件+Hooks模式。但很多开发者只是简单学会API调用,并没有吃透Hooks底层运行机制,在项目中频繁踩坑:闭包陷阱引发的状态陈旧、依赖数组乱写导致无限循环渲染、副作用忘记清理造成内存泄漏、状态更新逻辑不符合预期

本文结合三年真实项目开发中遇到的大量线上问题,围绕最核心的useStateuseEffect两大基础Hook,拆解高频误用场景,理清底层运行逻辑,给出可直接落地的编码规范与解决方案,帮助开发者避开90%的Hooks暗坑,写出健壮、稳定、易维护的React函数组件代码。

01 · Hooks运行底层基础:理解函数组件的渲染机制

想要用好useStateuseEffect,第一件事必须搞懂:函数组件每一次渲染,都是一次独立的函数执行

当组件触发重渲染,整个函数组件会从头到尾完整重新执行一遍,所有内部变量、函数全部重新创建生成。每一轮渲染拥有属于自己独立的props、state、事件处理函数。不同渲染周期之间,变量互相隔离,这是理解闭包陷阱的根本前提。

Counter.jsx — 每次渲染独立执行
function Counter() {
  const [count, setCount] = React.useState(0)

  function handleClick() {
    setCount(count + 1)
    console.log(count) // 永远打印更新前的值
  }

  return <button onClick={handleClick}>{count}</button>
}

点击按钮,界面数字增加,但是控制台打印出来的永远是更新之前的值。很多新手会疑惑,为什么状态更新之后拿不到最新count?本质就是:每一次渲染,count都是本次渲染的局部常量,handleClick捕获的是本次渲染作用域内部的count。调用setCount只会触发下一轮渲染,不会修改当前渲染周期里面的局部变量。

关键认知:setCount不会修改当前渲染作用域内的count,只会调度下一次重渲染;当前渲染中的state是只读常量。

🔄 函数组件渲染周期 — 每次渲染都是独立快照

渲染 #1: count = 0
handleClick 捕获 count=0
渲染 #2: count = 1
全新 handleClick,捕获 count=1
渲染 #3: count = 2
又一个全新 handleClick,捕获 count=2

Hooks调用规则

  1. 只在函数组件顶层调用Hooks,不能放在if、for循环、try/catch内部调用。React依靠调用顺序维护每一个Hook对应的状态,顺序错乱会直接造成状态错乱。
  2. 只能在React函数组件或者自定义Hook内部调用Hooks,普通JS函数不能直接调用Hooks。

02 · useState深度解析:常见踩坑点与最佳实践

useState是组件状态管理的基石,接收初始状态,返回[state, setState]。看似简单,业务中却充斥大量错误用法。

2.1 初始值惰性初始化,避免无用计算

useState(initialValue),如果直接传入复杂函数执行,组件每一次重渲染,这个函数都会执行一遍,造成不必要性能损耗。

❌ 错误
// 每次渲染都执行heavyCalc
const [value, setValue] =
  useState(heavyCalc(props.data))
✅ 惰性初始化
// 仅首次挂载执行一次
const [value, setValue] =
  useState(() => heavyCalc(props.data))

传入回调函数,React只会在组件初始化阶段执行回调拿到初始状态,后续重渲染直接跳过,适合初始状态需要复杂计算、大数据处理的场景。

2.2 状态更新两种写法:直接传值 vs 函数式更新

当新状态依赖上一次状态的值,一定要优先使用函数式更新。

❌ 连续更新只+1
setCount(count + 1)
setCount(count + 1)
// 两次读取同一渲染count
// 结果只+1
✅ 函数式更新+2
setCount(prev => prev + 1)
setCount(prev => prev + 1)
// prev始终拿到最新值
// 结果+2

setState是批量更新的,同一个渲染周期内count的值不会改变。上面连续两次直接setCount(count+1),两次读取的都是当前渲染同一个count,最终结果只会+1。使用函数式更新,回调参数prev永远拿到上一轮最新的状态,规避批量更新带来的逻辑错误。

编码规范:只要新状态依赖旧state,一律使用函数式更新语法。

2.3 对象类型状态,不可直接修改,必须完整替换

useState返回的setter不会做对象合并,和class组件this.setState行为不一样。很多新手直接修改state对象内部属性,界面不会发生任何更新。

对象/数组状态更新
const [form, setForm] = useState({username:"",phone:""})

// ❌ 直接修改属性,无引用变更,不触发重渲染
form.username = "张三"

// ✅ 展开生成全新对象
setForm(prev => ({...prev, username:"张三"}))

对象、数组类型状态,必须保证引用改变。数组不要直接使用pushsplice原地修改,需要返回全新数组。

❌ 原地修改数组
list.push(newItem)
setList(list)
✅ 生成新数组
setList(prev => [...prev, newItem])

2.4 useState不要滥用,合理拆分状态

不要把所有变量全部丢进useState。不需要驱动视图更新的变量,不要使用useState,改用useRef存储。

❌ 不需要渲染的变量
const [timerId, setTimerId] =
  useState(null)
✅ 使用useRef
const timerRef = useRef(null)
timerRef.current =
  setInterval(()=>{},1000)

state的核心目的:状态变更驱动UI渲染。如果一个变量变化不需要页面刷新,就不应该放在useState。

2.5 useState无法同步获取最新状态

调用setState之后,无法立刻拿到最新state。setState是异步调度,状态更新会等到下一轮渲染才生效。

setState异步示例
const [count, setCount] = useState(0)

function add() {
  setCount(count + 1)
  console.log(count) // 依旧是旧值
}

想要基于最新状态做逻辑,两种方案:

  1. setState函数式回调内部处理逻辑;
  2. 使用useEffect监听state变化,状态变更之后执行后续逻辑。

03 · useEffect:副作用处理,90%坑都来自依赖数组

useEffect是React处理副作用的核心Hook,处理数据请求、事件监听、定时器、订阅等操作。

📋 依赖数组三种模式

不传 每一次组件渲染都会执行effect
[] 仅组件挂载执行一次,卸载执行清理函数
[a, b] a 或 b 发生变化时重新执行effect

3.1 头号大坑:闭包陷阱,捕获旧的state/props

闭包陷阱是Hooks开发最高频问题。effect内部捕获的state、props,是effect执行那一刻渲染周期的值。如果依赖数组缺失依赖项,effect内部永远拿到旧数据。

❌ 闭包陷阱:count永远打印0
function Demo() {
  const [count, setCount] = useState(0)

  useEffect(()=>{
    setInterval(()=>{
      console.log(count) // 永远打印0
    },1000)
  }, []) // 空依赖,effect只执行一次

  return <div>{count}</div>
}

这里effect挂载运行一次,定时器回调捕获挂载阶段的count,永远是0。后续count更新,effect不会重新执行,定时器不会捕获新的值。

闭包陷阱三种解决方案

方案1:补全依赖数组(优先推荐)
✅ 补全依赖
useEffect(()=>{
  const timer = setInterval(()=>{
    console.log(count)
  },1000)
  return ()=> clearInterval(timer)
}, [count]) // count加入依赖,变更时重建定时器
方案2:使用函数式更新,不需要读取state — 如果只需要修改状态,不需要读取当前state
✅ 函数式更新规避读取
useEffect(()=>{
  const timer = setInterval(()=>{
    setCount(prev => prev + 1)
  },1000)
  return ()=> clearInterval(timer)
}, [])
方案3:useRef保存可变引用,绕开闭包捕获 — 不希望effect重复执行,又想拿到最新state
✅ useRef保存最新值
const [count, setCount] = useState(0)
const countRef = useRef(count)

// 每次渲染同步更新ref
useEffect(()=>{ countRef.current = count })

useEffect(()=>{
  const timer = setInterval(()=>{
    console.log(countRef.current) // 永远拿到最新值
  },1000)
  return ()=> clearInterval(timer)
}, [])

3.2 依赖数组乱写引发的无限循环

场景1:依赖数组直接传入对象字面量、函数字面量

❌ 每次渲染生成全新引用,无限循环
function Demo() {
  const [obj, setObj] = useState({a:1})

  // 每次渲染 {a:1} 生成全新对象,引用不同
  useEffect(()=>{
    fetchApi(obj)
  }, [{a:1}]) // ❌ 无限循环
}

解决:

  1. 如果对象来源于state,直接把state变量放入依赖;
  2. 如果是字面量常量,提升到组件外部定义,渲染不会重复创建。

场景2:effect内部修改依赖项自身

❌ effect读写同一依赖,无限循环
const [data, setData] = useState(null)

useEffect(()=>{
  async function load() {
    const res = await fetch("/api/list")
    setData(res) // 修改data → 依赖变化 → 重新执行 → 无限循环
  }
  load()
}, [data]) // ❌

这种接口请求场景,接口逻辑不需要依赖data,直接移除依赖即可。

3.3 副作用清理函数,至关重要,防止内存泄漏

useEffect回调函数可以返回一个清理函数。清理函数会在:1.下一次effect重新执行前;2.组件卸载的时候执行。定时器、事件监听、websocket、事件订阅,必须做清理。

❌ 无清理,内存泄漏
useEffect(()=>{
  setInterval(()=>{
    console.log("timer")
  },1000)
}, [])
✅ 返回清理函数
useEffect(()=>{
  const timer = setInterval(()=>{
    console.log("timer")
  },1000)
  return ()=> clearInterval(timer)
}, [])

常见需要清理的场景清单:

  1. setInterval / setTimeout 定时器;
  2. window、document事件监听addEventListener;
  3. websocket连接、事件订阅;
  4. 第三方库实例监听;
  5. 未完成的网络请求(AbortController中断请求)。

接口请求竞态问题:组件多次触发请求,旧请求晚于新请求返回,覆盖最新数据。使用AbortController中断上一次请求。

✅ AbortController解决竞态
useEffect(()=>{
  const controller = new AbortController()
  async function fetchData() {
    try{
      const res = await fetch("/api/detail", {
        signal: controller.signal
      })
    }catch(e){
      // 被中断的请求会抛出异常
    }
  }
  fetchData()
  return ()=> controller.abort()
}, [id])

3.4 不要把业务逻辑全部塞进useEffect

很多开发者把大量业务逻辑全部丢进useEffect,组件逻辑全部靠effect联动,代码难以阅读维护。

原则:事件触发的逻辑,写在事件处理函数里面;只有状态变化自动触发的逻辑,才写进useEffect。

❌ 用state做触发器
const [submitFlag, setSubmitFlag] =
  useState(false)

useEffect(()=>{
  if(submitFlag) submitForm()
}, [submitFlag])

function handleClick() {
  setSubmitFlag(true)
}
✅ 直接在事件中执行
function handleClick() {
  submitForm()
}

不要滥用state作为"触发器",不要为了执行逻辑而去改变状态。

3.5 React严格模式下useEffect执行两次

开发环境开启严格模式<React.StrictMode>,组件挂载的时候,effect会执行一次,执行清理,再重新执行一次。这是React刻意设计,目的是帮助开发者发现副作用清理逻辑遗漏的bug。

很多人误以为是bug,直接在effect里面加判断规避两次执行,这是错误做法。正确做法:保证effect清理函数完整可用,能够把effect产生的副作用全部销毁。线上生产环境不会触发该行为。

04 · 真实业务高频综合案例复盘

案例1:表单输入,防抖搜索

很多人写防抖搜索,踩闭包与依赖的坑。

❌ 闭包捕获旧keyword
useEffect(()=>{
  const timer = setTimeout(()=>{
    fetchSearch(keyword)
      .then(r=>setList(r))
  },500)
  return ()=>clearTimeout(timer)
}, []) // ❌ keyword未加入依赖
✅ keyword加入依赖
useEffect(()=>{
  const timer = setTimeout(async ()=>{
    const res = await fetchSearch(keyword)
    setList(res)
  },500)
  return ()=>clearTimeout(timer)
}, [keyword]) // ✅

案例2:封装自定义Hook,复用状态逻辑

基于useState、useEffect封装自定义Hook,实现逻辑抽离。自定义Hook本质就是普通函数,内部调用Hooks。

useTimer.js — 自定义Hook
function useTimer(interval = 1000) {
  const [count, setCount] = useState(0)

  useEffect(()=>{
    const timer = setInterval(()=>{
      setCount(prev => prev + 1)
    }, interval)
    return ()=> clearInterval(timer)
  }, [interval])

  return count
}

组件直接调用,每一次调用自定义Hook都会生成独立状态,互不干扰。

05 · Hooks编码规范清单,项目落地直接参考

useState 规范

  1. 初始状态需要复杂计算,使用惰性初始化回调
  2. 新状态依赖旧state,优先使用函数式更新 setX(prev=>newVal)
  3. 对象、数组状态,必须返回全新引用,禁止原地修改
  4. 不需要驱动视图更新的变量,使用useRef存储
  5. 不要依赖setState之后立刻读取state

useEffect 规范

  1. 严格补齐依赖数组所有依赖项,不要随意写空数组[]掩盖警告
  2. 定时器、事件监听、网络请求、订阅,必须编写清理函数
  3. 字面量对象、函数不要直接写进依赖数组
  4. 事件触发的逻辑,写在事件回调,不要强行放到useEffect
  5. 闭包陷阱优先补全依赖;不希望effect重执行时,使用useRef保存可变值
  6. 处理接口请求,使用AbortController做中断,解决竞态问题

调试排查思路

  1. 拿到旧state → 优先怀疑闭包陷阱,检查useEffect依赖数组
  2. 组件无限渲染 → 检查依赖对象/函数引用,检查effect内部修改依赖自身
  3. 内存泄漏 → 检查effect是否缺少清理函数
  4. 使用React DevTools Profiler查看渲染触发原因

06 · useRef、useCallback、useMemo补充

在实际开发中,仅仅依靠useState与useEffect还不够,经常搭配另外三个Hook解决衍生问题。

useRef

保存跨渲染周期共享可变数据,不会触发重渲染,解决闭包获取最新值,保存DOM节点

useCallback

缓存函数,避免每轮渲染生成全新函数,防止子组件不必要重渲染,同时稳定effect依赖项

useMemo

缓存计算结果,避免每轮渲染重复执行昂贵计算

useCallback示例
// 缓存函数,依赖不变,函数引用保持不变
const handleChange = useCallback((val)=>{
  setKeyword(val)
}, [])
⚠️ 不要滥用useCallback、useMemo,不要所有函数全部包一层useCallback,本身也有性能开销。只有两种场景才使用:①作为子组件props传递;②放入useEffect依赖数组需要稳定引用。

07 · Class组件与Hooks思维模式差异

很多从Class组件迁移过来的开发者,会带着class的思维写Hooks,习惯性去寻找"componentDidMount"、"componentDidUpdate"一一对应。

❌ Class 组件思维

  • 基于组件实例,this永远指向同一实例
  • 可以随时读取最新this.state
  • 生命周期方法:mount / update / unmount
  • 强求useEffect对应componentDidMount

✅ Hooks 函数组件思维

  • 每次渲染独立执行上下文
  • state是本次渲染的常量
  • 依靠依赖数组控制副作用执行
  • 关注状态变化,而非生命周期

不要强行把useEffect模拟各种生命周期,要切换思维:不要关注生命周期,关注状态变化。当A、B依赖发生变化,就执行这一段副作用逻辑

❌ 盲目写空依赖数组
// 不要把useEffect当componentDidMount用
// 盲目写空依赖数组,掩盖eslint警告,埋下闭包隐患
useEffect(()=>{
  // 业务逻辑用到了state/props,却没有写到依赖
},[])

空依赖数组不等于万能挂载钩子,当effect内部用到state/props,却没有写到依赖,就会埋下隐蔽bug。

08 · 写在最后

useStateuseEffect是React Hooks体系的基石,看上去API简单,但是背后是函数组件渲染模型、闭包、依赖比较、副作用生命周期一整套机制。大部分线上Hooks问题,并不是API本身缺陷,而是开发者没有理解底层运行逻辑,凭直觉写代码。

学习Hooks,不只是记住API怎么调用,更重要是建立渲染周期的思维模型:每一次渲染都是独立的快照,state是本次渲染的常量;effect由依赖驱动,并且必须做好清理。遵守编码规范,补齐依赖、做好副作用清理,就可以避开绝大多数Hooks暗坑,写出稳定、健壮、可维护的函数组件。

Hooks 核心思维模型

渲染即快照
依赖驱动
副作用清理
函数式更新

凡尘

前端工程化实践者,专注 React 生态与 Hooks 最佳实践