依赖数组不是「想让 effect 什么时候跑」的开关,而是「这个 effect 用到了哪些外部值」的声明。理解这一点,大部分 useEffect 的坑会自己消失。

闭包捕获的是渲染时的值
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
}
空依赖让 effect 只在挂载时执行一次,回调里读到的 count 就永远是首次渲染的值。修法有两种,取决于语义:
setCount(c => c + 1);
useEffect(() => {
const timer = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(timer);
}, [count]);
第一种用函数式更新,直接从源头消除依赖;第二种补全依赖,代价是每秒重建一次定时器,通常不是想要的效果。优先选能消除依赖的写法,而不是把依赖补全了事。
请求竞态:清理函数比依赖更重要
useEffect(() => {
let cancelled = false;
fetch(`/api/posts?q=${keyword}`)
.then(res => res.json())
.then(data => {
if (!cancelled) setPosts(data);
});
return () => { cancelled = true; };
}, [keyword]);
输入变化很快时,先发出的请求可能后返回,把旧结果盖在新结果上。cancelled 标记或 AbortController 都能解决,清理函数就是 React 为此提供的钩子。
依赖里的函数与对象
每次渲染都会创建新的函数与对象,直接放进依赖会让 effect 每次都执行。两种处理:把函数移到组件外,或用 useCallback 固定引用。
const load = useCallback(() => {
return fetch(`/api/posts/${id}`).then(res => res.json());
}, [id]);
useEffect(() => {
load().then(setPost);
}, [load]);
依赖数组里出现「不是 state 也不是 props 的值」,通常说明这个值应该在渲染时算出来,而不是在 effect 里读。
不需要 effect 的场景
- 由用户点击触发的请求:写在事件处理函数里;
- 根据 props 计算的派生数据:直接在渲染时算,或用
useMemo; - props 变化时重置组件状态:给组件加
key,让 React 直接重建。
开发模式下 effect 执行两次
StrictMode 会刻意「挂载 → 卸载 → 再挂载」,用来暴露清理不干净的问题。看到接口被请求两次不要急着关 StrictMode,先确认清理函数是否真的撤销了订阅与请求;生产构建不会双执行。
小结
写法优先级:能消除依赖就消除 → 必须依赖就用函数式更新或 useCallback 固定引用 → 一定要清理。重渲染本身的优化思路见React 重渲染优化。