← 中文资源

简体中文版本

React 无限渲染循环 — useState + useEffect 未使用依赖项

诊断并修复因在 useEffect 中无条件调用 setState 且缺少依赖数组而导致的无限渲染循环。包含 Profiler 定位、依赖数组检查、派生状态与稳定引用修复。

阅读英文原文

症状:Maximum update depth exceeded

当 React 组件陷入不受控的重复渲染时,会出现如下错误:

Warning: Maximum update depth exceeded. This can happen when a component
repeatedly calls setState inside useEffect or componentDidUpdate.
React limits the number of nested updates to prevent infinite loops.

在 React 18+ 中,React 大约在 50 次更新后中止;在 React 17 及更早版本中,浏览器标签页可能直接卡死。

典型根因

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

  useEffect(() => {
    setCount(count + 1); // ❌ 每次渲染后都会执行 → 再次触发渲染
  }); // ❌ 没有依赖数组 = 每次渲染后都执行

  return <div>{count}</div>;
}

执行过程:

  1. 首次渲染:count = 0
  2. useEffect 执行:setCount(1) → 安排下一次渲染
  3. 第二次渲染:count = 1
  4. useEffect 再次执行:setCount(2) → 再次安排渲染
  5. 重复,直到 React 中止(超过约 50 次更新)
  6. 抛出错误,组件状态可能已被破坏

分步诊断

第一步:用 React DevTools Profiler 确认循环

打开 Chrome DevTools → Components → Settings,勾选 “Highlight updates when components render”。打开受影响页面;如果某个组件持续闪烁,它就是循环组件。

第二步:定位问题 useEffect

临时加入计数器:

useEffect(() => {
  console.log('effect ran, count:', count);
  setCount(count + 1);
});

打开控制台。如果日志在没有用户点击的情况下快速重复出现,就能确认循环。

第三步:检查依赖数组

审查每一个调用 setState 的 useEffect:

// ❌ 有问题(没有依赖 = 每次渲染执行)
useEffect(() => { setUser(filteredUser); });

// ❌ 有问题(effect 修改的状态又出现在依赖中)
useEffect(() => { setCount(count + 1); }, [count]);

// ✅ 只在挂载时执行一次
useEffect(() => { setCount(initialValue); }, []);

// ✅ 派生更新使用回调形式
useCount((prev) => prev + 1);

// ✅ 用条件保护
useEffect(() => {
  if (count < 10) setCount(count + 1);
}, [count]);

修复模式

模式 A:从 props 初始化状态

// ❌ 每次渲染都覆盖用户修改
useEffect(() => {
  setLocalName(props.name);
});

// ✅ 更好:直接在 useState 中初始化
const [localName, setLocalName] = useState(props.name);

// ✅ 只在身份标识变化时重置
useEffect(() => {
  setLocalName(props.name);
}, [props.id]); // 用户身份变化时才重置,而不是每次渲染

模式 B:派生状态

// ❌ 重复状态 → 同步 effect
const [items, setItems] = useState([]);
const [itemCount, setItemCount] = useState(0);
useEffect(() => { setItemCount(items.length); }, [items]);

// ✅ 直接计算派生值(无需 effect、无需同步)
const itemCount = items.length;

模式 C:为对象/数组依赖提供稳定引用

// ❌ 每次渲染都产生新对象引用 → 无限循环
useEffect(() => {
  fetch(`/api/data?filter=${JSON.stringify(filter)}`);
}, [filter]); // filter 每次渲染都是新的 {}

// ✅ 记忆化或使用原始值依赖
const filterKey = useMemo(() => `${filter.type}-${filter.status}`, [filter.type, filter.status]);
useEffect(() => {
  fetch(`/api/data?filter=${filterKey}`);
}, [filterKey]);

风险提示

如果循环出现在认证、支付或导航等关键路径上,浏览器可能在修复前就失去响应。可以使用浏览器标签页的 “kill page” 按钮,或在 DevTools 中暂停脚本以打断循环。

验证修复

  1. 重新加载页面
  2. React DevTools 中组件初始加载应只渲染 1–3 次
  3. 空闲时 CPU 占用应低于 5%
  4. 控制台不再出现 “Maximum update depth exceeded”

预防清单

  • 每个更新状态的 useEffect 都必须有依赖数组
  • 从其他状态派生的值应使用 useMemo,而不是同步 effect
  • props 到 state 的同步 effect 应使用语义化守卫(id、版本号),而不是整个 prop 对象
  • 使用 eslint-plugin-react-hooks 在构建期捕获缺失依赖

来源