React Hooks 刚上手:容易乱的是副作用,不是 API 本身
我给自己整理 Hooks 笔记时定了一条规矩:每一个"容易乱"的点,都得能用一段脱离 React 的纯 JS 复现出来,不然就说明我还没真懂,只是背下了结论。真正逼我做这件事的,是从 class 组件切到 Hooks 之后遇到的三个反复出现的问题——请求重复发、按钮里读到的是旧状态、effect 一直循环。它们表面上是三个 bug,追到底其实是同一套东西:函数组件每次渲染都会重新执行,闭包和依赖数组决定了你到底捕获到哪一刻的值。
Hooks 是 React 16.8 引入的一组能力,让函数组件也能拥有状态、副作用、上下文和复杂状态管理。它带来的真正变化不只是"class 换函数",而是组件逻辑可以按功能组织,而不是被拆散在生命周期方法里。写得好时组件更清晰,写不好照样会冒出重复请求、闭包旧值、依赖数组混乱这些问题。下面这份笔记就按我踩到它们的顺序来。
useState:组件自己的状态
useState 是最基础的 Hook,它让函数组件拥有本地状态。
1import { useState } from "react"; 2 3function Counter() { 4 const [count, setCount] = useState(0); 5 6 return ( 7 <div> 8 <p>You clicked {count} times</p> 9 <button onClick={() => setCount(count + 1)}>Click me</button> 10 </div> 11 ); 12}
它返回当前状态和更新方法。调用 setCount 后 React 重新渲染,用新值再走一遍组件函数。如果新状态依赖旧状态,我更推荐函数式写法:
1setCount((prevCount) => prevCount + 1);
这样能避开连续更新、异步回调或批处理里读到旧值的坑。
为什么 setCount(count + 1) 连写两遍不一定加 2?因为同一次渲染里 count 是个被冻结的常量快照,两次都读到同一个旧值。我用纯 JS 把这个批处理复现了一遍,验证自己没理解错:
1let count = 0; 2const setCount = (next) => 3 (count = typeof next === "function" ? next(count) : next); 4 5// 模拟"同一次渲染里 count 这个变量被冻结成 0" 6const snapshot = 0; 7setCount(snapshot + 1); 8setCount(snapshot + 1); 9console.log(count); // 1 —— 两次都基于旧值 0,第二次覆盖第一次 10 11count = 0; 12setCount((c) => c + 1); 13setCount((c) => c + 1); 14console.log(count); // 2 —— 函数式更新拿到的是上一次的结果
还有一个我早期栽过的点:useState 的更新是替换而不是合并。这跟 class 的 this.setState 不一样——后者会把传入对象浅合并进 state,而 useState 的 setter 直接整个替换。所以对象状态得自己展开旧值:
1// class:会自动合并,age 保留 2this.setState({ name: "Tom" }); 3 4// hooks:直接替换,少写 ...user 就把 age 丢了 5setUser({ name: "Tom" }); // 错误:age 没了 6setUser((u) => ({ ...u, name: "Tom" })); // 正确:手动合并
useState 不适合所有数据
不是所有变量都该塞进 useState。适合放进状态的数据通常有两个特征:会影响渲染,且变化后需要触发重新渲染。计数、输入框内容、弹窗开关、当前选中的 tab 就属于这类。
如果只是临时计算值,直接在渲染里算出来即可:
1function UserCard({ user }) { 2 const displayName = user.nickname || user.name || "匿名用户"; 3 4 return <span>{displayName}</span>; 5}
别为了"看起来统一"把所有派生值都存进状态。派生状态越多,越容易出同步问题。我见过不少 bug 就来自这里:selectedUser 明明能从 users 和 selectedId 算出来,却又单独存一份,列表刷新后用户对象换了,selectedUser 还攥着旧引用,弹窗里显示的就是过期数据。能算就算,别为省一行 find 多养一份状态。真要缓存一份开销大的计算结果,那是 useMemo 的活儿,但它管的是"避免重复计算",不是"多存一份真相",两者别混。
useEffect:处理副作用
useEffect 处理渲染之外的事:请求数据、订阅事件、操作浏览器 API、同步标题。
1import { useEffect, useState } from "react"; 2 3function PostTitle({ postId }) { 4 const [title, setTitle] = useState(""); 5 6 useEffect(() => { 7 let ignore = false; 8 9 async function loadPost() { 10 const response = await fetch(`/api/posts/${postId}`); 11 const data = await response.json(); 12 13 if (!ignore) { 14 setTitle(data.title); 15 } 16 } 17 18 loadPost(); 19 20 return () => { 21 ignore = true; 22 }; 23 }, [postId]); 24 25 return <h1>{title}</h1>; 26}
依赖数组 [postId] 表示 postId 变化时重跑 effect,清理函数里的 ignore 用来防止旧请求回来覆盖新状态。这个 ignore 标志值得单独记一笔:postId 快速切换时会并发好几个请求,谁先回来不确定,没有它就可能出现"点了 B,屏幕上却是 A 的数据"这种竞态。
实际项目里请求还有错误处理、加载状态、取消、缓存这些问题。简单页面直接写在 effect 里没问题,复杂场景我更愿意交给专门的请求状态管理工具,把这些重复逻辑收拢起来,比如社区里的 TanStack Query 就把加载、缓存、失效这套做成了通用能力。
如果一个 effect 只是因为某个 props 变化就去同步本地状态,得先警惕是不是可以直接用 props。很多 effect 其实是在弥补状态设计的问题。React 官方文档专门写了一篇 "You Might Not Need an Effect",这个提醒很实用:能在渲染时算的别放 effect,能在事件里做的别绕到 effect。
useEffect 的常见误区
很多 Hooks 问题都挂在 useEffect 上。
第一个误区是把它当"组件加载时执行一次"。空依赖数组确实只在挂载后跑一次,但 effect 的定位是副作用同步,不是生命周期方法的简单替身。顺带说个 React 18 的细节:开发环境下开了严格模式,effect 会被故意挂载、卸载、再挂载一次,好帮你暴露没写清理函数的副作用。第一次遇到"怎么请求发了两遍"别慌,先确认是不是严格模式在生产里不会这样,真正该补的是清理逻辑。
第二个误区是依赖数组随便写。漏了依赖可能读到旧值,多了依赖可能反复执行。ESLint 的 exhaustive-deps 提醒你时,别第一反应就禁用规则,先查 effect 里到底用了哪些外部变量。
第三个误区是在 effect 里做本该在事件里做的事。点击按钮提交表单,通常就该在点击事件里直接调,而不是先改一个状态、再靠 effect 观察这个状态去提交,绕这一圈只会让时序更难追。
useContext:跨层传数据
数据要被多层组件用到时,useContext 能免去一层层传 props。
1import { createContext, useContext } from "react"; 2 3const ThemeContext = createContext("light"); 4 5function Toolbar() { 6 const theme = useContext(ThemeContext); 7 return <button className={theme}>Save</button>; 8} 9 10function App() { 11 return ( 12 <ThemeContext.Provider value="dark"> 13 <Toolbar /> 14 </ThemeContext.Provider> 15 ); 16}
它适合放全局主题、语言、当前用户、权限这类跨层数据,但不是所有状态管理问题的答案。如果一个 context 的值频繁变化、又被很多组件消费,会引发较大范围的重新渲染。真实项目里要注意拆分 context,别把"所有全局状态"塞进一个大对象。
Context 更适合低频变化的全局信息,这是它的天然使用范围。把输入框内容、列表筛选、实时计数这类高频状态放进一个大 Context,性能问题很快会浮出来。必要时拆 context,或者引入更细粒度的方案——社区里 Zustand 这类轻量状态库这两年就常被拿来接手这种高频、跨组件的共享状态。
useReducer:复杂状态更清楚
当状态的变化规则比较复杂时,useReducer 比堆一堆 useState 更好维护。请求状态就是典型例子:
1import { useReducer } from "react"; 2 3const initialState = { 4 loading: false, 5 data: null, 6 error: null, 7}; 8 9function reducer(state, action) { 10 switch (action.type) { 11 case "start": 12 return { loading: true, data: null, error: null }; 13 case "success": 14 return { loading: false, data: action.payload, error: null }; 15 case "error": 16 return { loading: false, data: null, error: action.payload }; 17 default: 18 return state; 19 } 20} 21 22function UserPanel() { 23 const [state, dispatch] = useReducer(reducer, initialState); 24 25 // 根据 state.loading、state.data、state.error 渲染 26}
它的好处是把"状态怎么变"集中到一个纯函数里。表单流程、弹窗步骤、请求状态、复杂筛选,用它比让多个状态散落在组件各处稳。而且 dispatch 的引用是稳定的,放进子组件的依赖或 useCallback 里不会引起额外重渲染,这点在跨层传更新方法时很省心。
自定义 Hook:复用的是逻辑,不只是代码
多个组件里出现相似逻辑时,可以提取自定义 Hook。
1import { useEffect, useState } from "react"; 2 3function useWindowWidth() { 4 const [width, setWidth] = useState(window.innerWidth); 5 6 useEffect(() => { 7 function handleResize() { 8 setWidth(window.innerWidth); 9 } 10 11 window.addEventListener("resize", handleResize); 12 return () => window.removeEventListener("resize", handleResize); 13 }, []); 14 15 return width; 16}
自定义 Hook 的名字必须以 use 开头,React 和 ESLint 才能按 Hooks 规则识别它。
提取时要盯住一条线:它该复用一段稳定的逻辑,而不是为了少写几行强行抽象。如果一个 Hook 需要十几个参数才能兼容不同页面,多半是抽早了。我现在写自定义 Hook 会先问一句:它复用的是业务规则,还是只是把代码挪了个地方。如果只为了让组件短点,把一堆页面专属状态塞进 usePageLogic,可读性未必更好。好的 Hook 有明确的输入输出,最好脱离页面名字也能读懂。
Hooks 的基本规则
Hooks 有两条硬规则:只能在 React 函数组件或自定义 Hook 的顶层调用,不能在条件、循环、嵌套函数里调用。
别这样写:
1if (visible) { 2 const [count, setCount] = useState(0); 3}
React 靠 Hooks 的调用顺序来关联状态。一旦条件执行让顺序变了,状态就会错位。这也是为什么规则听起来死板,实际是渲染机制的硬约束。
还要盯住闭包旧值。定时器、事件监听、Promise 回调里读到的状态,可能是创建那一刻的值。碰到这类问题,用函数式更新、补全依赖、或者用 useRef 存最新值,别靠"禁用 exhaustive-deps"硬压过去,很多线上问题就是这么埋下的。
这个"读到旧值"的原理,我也用纯 JS 复现过一遍,跟 React 没关系——每次渲染相当于拿当时的 state 重新造一批函数,函数闭包住的就是那一刻的值:
1// 模拟"每次渲染都用当时的 count 造一个回调" 2function render(count) { 3 return () => console.log("看到的 count =", count); 4} 5 6const callbackFromRender0 = render(0); 7const callbackFromRender1 = render(1); 8 9callbackFromRender0(); // 看到的 count = 0 10callbackFromRender1(); // 看到的 count = 1
setInterval(callback, 1000) 的坑就在这:如果它只在挂载时(count 还是 0 那次渲染)注册了一次,就永远攥着 callbackFromRender0,之后界面上 count 怎么变,定时器里读到的都是 0。函数式更新 setCount(c => c + 1) 能绕开,是因为它压根不读闭包里的 count,而是让 React 把最新值传进来。
值得较真的是判断,不是 API 数量
回到开头那份规矩——正是这几段纯 JS 复现,帮我把三个 bug 归到了同一个根上:effect 重复请求、定时器读到旧状态、自定义 Hook 名字漂亮里面全是页面细节,追到底都是"这一次渲染捕获到了哪一刻的值"。
入门阶段掌握 useState、useEffect、useContext、useReducer 就够用了。值得反复推敲的是几个判断:这份状态该不该存在,effect 是不是必要,依赖数组有没有说实话,自定义 Hook 复用的是规则还是只是搬代码。
Hooks 真正解决的问题,是让请求重复发、按钮读到旧状态、effect 死循环这类问题,能被拆解成"这一刻的值到底是谁捕获的"去回答,而不是靠试出来的写法蒙混过去,让组件"看起来更函数式"只是这套机制顺带带来的效果。哪天一个 Hooks bug 又说不清楚是怎么回事,不妨还是用一段纯 JS 把它复现一遍——复现不出来,大概率是理解本身还有漏洞。