useState 和 useEffect: 正确的用法

1. 概述

useState 和 useEffect 是 React 最基础的两个 hook,但也是最容易产生 bug 的地方:组件无限渲染、state 没有按预期更新、effect 不必要地重复运行。这篇文章会讲解这两个 hook 的真实工作机制、正确的使用模式和应该避免的常见错误。读完后,当你需要判断“这里是否需要 useEffect“时,你会更有信心——这个问题很多 React 开发者至今仍然回答错误。

2. useState 基础

useState 返回一个 [value, setter] 对。每次调用 setter,React 就会安排 component 用新值重新渲染。

import { useState } from "react";

function CustomerCounter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>客户数: {count}</p>
      <button onClick={() => setCount(count + 1)}>添加客户</button>
    </div>
  );
}
  • useState(0) 在首次渲染时初始化 count = 0。
  • setCount(count + 1) 会创建一个新的渲染,其中 count 增加 1。
  • 在当前渲染的 closure 中,count 是不可变的——在两次渲染之间它不会自己改变。

注意: 调用 setCount 不会立即改变同一次渲染中 count 的值。React 会批量处理更新,然后重新渲染。

3. useState: 基于旧 state 更新

如果你在同一个 event handler 中多次调用 setter,而都基于 count(这个变量已经被 closure “冻结”),结果会是错误的。

function handleTripleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
  // 结果:count 只增加 1,而不是 3
}

使用 functional update 来获得正确的最新值:

function handleTripleClick() {
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
  // 结果:count 正确增加 3
}
  • 当你把一个函数传递给 setter 时,React 保证 prev 总是此时最新的 state 值,而不是 closure 中捕获的旧值。
  • 规则:如果新 state 依赖旧 state,总是使用 functional update。

4. useState: 惰性初始化

如果初始值需要昂贵的计算,不要直接调用函数——而是把函数传递给 useState,这样它只会运行一次。

function parseInitialOrders(raw) {
  console.log("正在解析订单数据...");
  return JSON.parse(raw);
}

function OrderList({ rawOrders }) {
  // 错误:parseInitialOrders 在每次渲染时都会运行
  // const [orders, setOrders] = useState(parseInitialOrders(rawOrders));

  // 正确:只在首次渲染时运行
  const [orders, setOrders] = useState(() => parseInitialOrders(rawOrders));

  return <p>订单数:{orders.length}</p>;
}
  • 错误的方式在每次渲染时都调用 parseInitialOrders,尽管 React 只使用第一次的结果。
  • 正确的方式是传递一个函数引用;React 只在首次挂载时调用它。

5. useEffect 基础

useEffect 在组件渲染完成之后运行一个 side effect(在 DOM 已经更新之后)。

import { useEffect, useState } from "react";

function PageTitleUpdater({ orderCount }) {
  useEffect(() => {
    document.title = `你有 ${orderCount} 个订单`;
  });

  return <p>当前订单:{orderCount}</p>;
}
  • 没有 dependency array(第二个参数),这个 effect 在每次渲染之后都会运行——无论是 mount 还是 update。
  • document.title 是典型的 side effect:它与 React 之外的世界(DOM API)交互,而不是计算用于渲染的值。

6. useEffect: dependency array

Dependency array 决定了 effect 何时运行。有 3 种情况需要清楚记住。

6.1. 没有 dependency array

useEffect(() => {
  console.log("在每次渲染之后运行");
});

6.2. Dependency array 为空 []

useEffect(() => {
  console.log("只在 mount 之后运行一次");
}, []);

6.3. Dependency array 包含值

function CustomerProfile({ customerId }) {
  const [customer, setCustomer] = useState(null);

  useEffect(() => {
    fetchCustomer(customerId).then(setCustomer);
  }, [customerId]);

  return <p>{customer?.name ?? "加载中..."}</p>;
}
  • Effect 只在 customerId 在两次渲染之间变化时重新运行(使用 Object.is 比较)。
  • 如果你在数组中漏掉 customerId,effect 会使用旧的 customerId 值(stale closure)——当 prop 改变时,fetch 会获取错误的客户。

注意: ESLint 插件 eslint-plugin-react-hooks 的 exhaustive-deps 规则会在你漏掉依赖时警告。除非你完全理解后果,否则不要关闭这条规则。

7. useEffect: cleanup 函数

当 effect 创建 subscription、timer 或 event listener 时,你必须返回一个 cleanup 函数来避免泄漏。

function OrderStatusListener({ orderId }) {
  const [status, setStatus] = useState("pending");

  useEffect(() => {
    const subscription = subscribeToOrderStatus(orderId, setStatus);

    return () => {
      subscription.unsubscribe();
    };
  }, [orderId]);

  return <p>状态:{status}</p>;
}
  • Cleanup 函数在 effect 重新运行之前(当 orderId 改变时)以及组件 unmount 时运行。
  • 如果没有 cleanup,每次 orderId 改变都会创建一个新的 subscription——最终 setStatus 会被多个旧的 subscription 调用,导致更新错误或内存泄漏。
当 orderId 从 1 改变到 2 时的运行顺序:
1. orderId = 1 的 effect 的 cleanup
2. orderId = 2 的新 effect 运行

8. 常见错误

8.1. 因为缺少正确的依赖项导致无限循环

function BadExample() {
  const [data, setData] = useState({ count: 0 });

  useEffect(() => {
    // 每次渲染都是新的 object -> 如果把 data 放在依赖项中,effect 总是看到"变化"
    setData({ count: data.count + 1 }); // 导致重新渲染 -> effect 重新运行 -> 无限循环
  }, [data]);

  return <p>{data.count}</p>;
}

可以通过 functional update 和分离停止条件来修复,或者如果这个逻辑不需要真正的 side effect,可以完全移除 useEffect。

8.2. setTimeout/setInterval 中的 stale closure

function Countdown() {
  const [seconds, setSeconds] = useState(10);

  useEffect(() => {
    const id = setInterval(() => {
      setSeconds((prev) => prev - 1); // 使用 functional update,不要直接用 seconds
    }, 1000);

    return () => clearInterval(id);
  }, []); // 依赖项为空,因为 effect 中没有直接读取 seconds

  return <p>{seconds}</p>;
}
  • 如果写 setSeconds(seconds - 1) 而不是 functional update,closure 会永远“记住” seconds = 10(来自 effect 首次运行时)——倒计时会卡在错误的值。

8.3. 使用 useEffect 计算派生值

// 错误:多余的,导致额外渲染一次
function OrderSummaryBad({ items }) {
  const [total, setTotal] = useState(0);

  useEffect(() => {
    setTotal(items.reduce((sum, i) => sum + i.price, 0));
  }, [items]);

  return <p>总计:{total}</p>;
}

// 正确:在渲染过程中直接计算
function OrderSummaryGood({ items }) {
  const total = items.reduce((sum, i) => sum + i.price, 0);
  return <p>总计:{total}</p>;
}

“错误”版本多出了一对渲染:先用旧 total 渲染,然后 effect 运行,setTotal 触发第二次渲染得到正确值。“正确”版本在渲染过程中直接计算,不需要 effect,不需要额外的 state。

9. 何时不应该使用 useEffect

注意: useEffect 应该只用于与 React 之外的系统同步(DOM、API、subscription、timer)。如果你可以直接从现有的 props/state 计算值,就不要使用 effect。

你在错误使用它的三个迹象:

  • 你用 effect 计算一个从其他 state 派生的值(应该用普通变量或 useMemo)。
  • 你用 effect 处理用户事件(应该直接在 onClick、onSubmit 等 handler 中处理)。
  • 你用 effect 在 prop 改变时重置 state——通常可以用 key prop 让 React 自动重新挂载组件,而不是使用 effect。
// 不要用 effect 在 customerId 改变时重置表单:
// useEffect(() => { setForm(initialForm); }, [customerId]);

// 用 key 让 React 自动重新挂载组件,自动重置所有内部 state:
<CustomerForm key={customerId} customerId={customerId} />

10. 为 hook 的行为编写测试

使用 Jest 和 React Testing Library 来测试实际行为,而不是实现细节。

npm install --save-dev @testing-library/react @testing-library/user-event jest
// CustomerCounter.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CustomerCounter } from "./CustomerCounter";

test("点击添加客户按钮时 count 增加", async () => {
  const user = userEvent.setup();
  render(<CustomerCounter />);

  expect(screen.getByText("客户数: 0")).toBeInTheDocument();

  await user.click(screen.getByRole("button", { name: "添加客户" }));

  expect(screen.getByText("客户数: 1")).toBeInTheDocument();
});
  • 测试调用 render,然后通过 userEvent 模拟真实的点击,就像真实用户的行为一样。
  • 断言基于显示的内容(getByText),而不是内部 state——这是用 React 正确的测试方式:测试行为,不测试实现。
PASS  ./CustomerCounter.test.jsx
  ✓ 点击添加客户按钮时 count 增加 (45 ms)

11. 总结

useState 看起来很简单,但当你忘记 closure 中的值在每次渲染时都被“冻结”时,很容易出错——当新 state 依赖旧 state 时,要使用 functional update。useEffect 很强大,但应该只用于与 React 之外的系统同步;如果一个值可以直接在渲染过程中计算,就不要把它包装在 effect 中。掌握 dependency array 和 cleanup 函数,可以帮助你避免与这两个 hook 相关的大多数常见 bug。

reacthooksjavascriptfrontendtypescript