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——通常可以用
keyprop 让 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。