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 明明能从 usersselectedId 算出来,却又单独存一份,列表刷新后用户对象换了,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 名字漂亮里面全是页面细节,追到底都是"这一次渲染捕获到了哪一刻的值"。

入门阶段掌握 useStateuseEffectuseContextuseReducer 就够用了。值得反复推敲的是几个判断:这份状态该不该存在,effect 是不是必要,依赖数组有没有说实话,自定义 Hook 复用的是规则还是只是搬代码。

Hooks 真正解决的问题,是让请求重复发、按钮读到旧状态、effect 死循环这类问题,能被拆解成"这一刻的值到底是谁捕获的"去回答,而不是靠试出来的写法蒙混过去,让组件"看起来更函数式"只是这套机制顺带带来的效果。哪天一个 Hooks bug 又说不清楚是怎么回事,不妨还是用一段纯 JS 把它复现一遍——复现不出来,大概率是理解本身还有漏洞。