正在加载,请稍候…

React Hooks:常见错误及修复方法

理解并修复最常见的 React Hooks 错误——闭包过期、useEffect 依赖、无限重渲染以及 useState 对象误用。

React Hooks:常见错误及修复方法

为什么 React Hooks 容易出错

React Hooks 引入了一种新的心智模型,组件状态和副作用与函数调用绑定。大多数 bug 源于未能完全理解闭包、依赖数组和 React 渲染周期之间的交互。

本指南涵盖了最常见的错误及其修复方法。

React Hooks:常见错误及修复方法 插图

错误 1:useEffect 中的闭包过期

这是最常见且最难调试的 React Hook bug。

// ❌ Bug:count 已过期——在 interval 内部始终为 0
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      console.log('Count:', count); // 始终为 0
      setCount(count + 1);         // 始终设置为 1 (0 + 1)
    }, 1000);
    return () => clearInterval(interval);
  }, []); // [] 表示“只运行一次”——闭包永远捕获 count=0

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

修复 1:使用函数式状态更新器

// ✅ 函数式更新不需要捕获 count
useEffect(() => {
  const interval = setInterval(() => {
    setCount(prev => prev + 1);  // 始终获取当前值
  }, 1000);
  return () => clearInterval(interval);
}, []);

修复 2:添加到依赖数组(导致重新注册)

// ✅ 当 count 变化时 effect 重新运行
useEffect(() => {
  const interval = setInterval(() => {
    setCount(count + 1);  // 现在使用当前的 count
  }, 1000);
  return () => clearInterval(interval);
}, [count]); // 但每次 count 变化都会重新创建 interval

修复 3:使用 useRef 存储可变值

// ✅ ref 始终是最新的,不会触发重渲染
function Counter() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;

  useEffect(() => {
    const interval = setInterval(() => {
      setCount(countRef.current + 1);
    }, 1000);
    return () => clearInterval(interval);
  }, []);

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

错误 2:useEffect 依赖不正确

ESLint 的 react-hooks/exhaustive-deps 规则标记缺失依赖是有原因的。

// ❌ 缺少依赖:userId
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, []); // Bug:当 userId 变化时不会重新获取

  return <div>{user?.name}</div>;
}
// ✅ 将 userId 包含在依赖中
useEffect(() => {
  fetchUser(userId).then(setUser);
}, [userId]); // 当 userId 变化时重新获取

对象和函数依赖

// ❌ Bug:options 对象在每次渲染时重新创建
function DataFetcher({ userId }) {
  const options = { headers: { 'X-User': userId } }; // 每次渲染新对象

  useEffect(() => {
    fetch('/api/data', options).then(/* ... */);
  }, [options]); // 无限循环!options 始终在变化
}

// ✅ 修复 1:将对象移到 effect 内部
useEffect(() => {
  const options = { headers: { 'X-User': userId } };
  fetch('/api/data', options).then(/* ... */);
}, [userId]); // 只依赖原始值

// ✅ 修复 2:使用 useMemo 稳定对象
const options = useMemo(
  () => ({ headers: { 'X-User': userId } }),
  [userId]
);

React Hooks:常见错误及修复方法 插图

错误 3:无限重渲染循环

// ❌ 无限循环:effect 设置状态,导致重渲染,再次运行 effect
function BadComponent() {
  const [data, setData] = useState([]);

  useEffect(() => {
    setData([...data, 'item']); // 触发重渲染 → effect 再次运行
  }); // 没有依赖数组 = 每次渲染后运行

  return <div>{data.length}</div>;
}
// ❌ 无限循环:依赖中的对象每次渲染重新创建
function AlsoBad() {
  const [result, setResult] = useState(null);
  const config = { timeout: 5000 }; // 每次渲染新对象

  useEffect(() => {
    fetch('/api', config).then(r => r.json()).then(setResult);
  }, [config]); // config 每次渲染都变化 → 无限循环
}

// ✅ 修复:使用 useMemo、useCallback 或将值移到 effect 内部
const config = useMemo(() => ({ timeout: 5000 }), []); // 稳定引用

错误 4:直接修改状态

React 状态更新只有在替换引用时才会触发重渲染,原地修改不会。

// ❌ 修改:React 检测不到变化
const [items, setItems] = useState(['a', 'b', 'c']);

function addItem() {
  items.push('d'); // 修改数组
  setItems(items); // 相同引用!React 跳过更新
}

// ✅ 创建新数组
function addItem() {
  setItems([...items, 'd']); // 新引用 → 重渲染
}

// ❌ 对象修改
const [user, setUser] = useState({ name: 'Alice', age: 30 });
user.name = 'Bob'; // 修改
setUser(user);     // 相同引用,不重渲染

// ✅ 使用展开创建新对象
setUser({ ...user, name: 'Bob' });

嵌套对象更新

const [profile, setProfile] = useState({
  user: { name: 'Alice', address: { city: 'NYC', zip: '10001' } }
});

// ❌ Bug:嵌套修改
profile.user.address.city = 'LA';
setProfile(profile);

// ✅ 深层展开
setProfile({
  ...profile,
  user: {
    ...profile.user,
    address: {
      ...profile.user.address,
      city: 'LA',
    },
  },
});

// ✅ 更好的做法:使用 Immer
import { produce } from 'immer';

setProfile(produce(draft => {
  draft.user.address.city = 'LA'; // Immer 使修改安全
}));

React Hooks:常见错误及修复方法 插图

错误 5:过度使用 useCallback / useMemo

// ❌ 无意义的记忆化——比什么都不做更消耗资源
function SimpleComponent({ value }) {
  // 包装一个简单函数浪费内存和 CPU
  const add = useCallback((a, b) => a + b, []); // 直接写:const add = (a, b) => a + b
  const doubled = useMemo(() => value * 2, [value]); // 直接写:const doubled = value * 2
}

// ✅ useCallback 真正有用时:传递给记忆化子组件的回调
const handleClick = useCallback(() => {
  setCount(c => c + 1);
}, []); // 稳定引用 → 防止 MemoizedChild 重渲染

return <MemoizedChild onClick={handleClick} />;

// ✅ useMemo 真正有用时:昂贵计算
const filteredItems = useMemo(
  () => items.filter(item => item.category === category && item.price < maxPrice),
  [items, category, maxPrice]
); // 仅在输入变化时重新计算,而非每次渲染

错误 6:条件调用 Hooks

// ❌ 违反 Hooks 规则
function BadComponent({ isLoggedIn }) {
  if (isLoggedIn) {
    const [user, setUser] = useState(null); // 条件调用 hook
  }
  // ...
}

// ✅ 始终在顶层调用 hooks,在内部使用条件
function GoodComponent({ isLoggedIn }) {
  const [user, setUser] = useState(null); // 始终调用

  useEffect(() => {
    if (!isLoggedIn) return; // 条件在 effect 内部
    fetchUser().then(setUser);
  }, [isLoggedIn]);
}

错误 7:未清理 Effect

// ❌ 内存泄漏 / 竞态条件:在已卸载组件上设置状态
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(data => {
      setUser(data); // 可能在组件卸载后运行
    });
  }, [userId]);
}

// ✅ 使用 AbortController 在清理时取消请求
useEffect(() => {
  const controller = new AbortController();

  fetchUser(userId, { signal: controller.signal })
    .then(setUser)
    .catch(err => {
      if (err.name !== 'AbortError') throw err; // 忽略中止错误
    });

  return () => controller.abort(); // 清理:取消正在进行的请求
}, [userId]);

错误 8:useState 处理复杂对象——考虑 useReducer

// ❌ 多个相关状态更新不同步
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);

async function loadData() {
  setLoading(true);
  try {
    const result = await fetch('/api');
    setData(result);  // 这三个更新导致 3 次渲染
    setLoading(false);
    setError(null);
  } catch (err) {
    setError(err);
    setLoading(false);
  }
}

// ✅ useReducer 将相关状态更新分组
const [state, dispatch] = useReducer(reducer, {
  status: 'idle', // 'idle' | 'loading' | 'success' | 'error'
  data: null,
  error: null,
});

function reducer(state, action) {
  switch (action.type) {
    case 'FETCH_START':  return { ...state, status: 'loading', error: null };
    case 'FETCH_SUCCESS': return { status: 'success', data: action.payload, error: null };
    case 'FETCH_ERROR':  return { ...state, status: 'error', error: action.error };
    default: return state;
  }
}

async function loadData() {
  dispatch({ type: 'FETCH_START' });
  try {
    const data = await fetch('/api').then(r => r.json());
    dispatch({ type: 'FETCH_SUCCESS', payload: data }); // 一次原子更新
  } catch (err) {
    dispatch({ type: 'FETCH_ERROR', error: err });
  }
}

快速参考

Bug 根本原因 修复方法
Effect 中的值过期 闭包捕获了旧状态 函数式更新器或正确的依赖
Effect 运行过于频繁 依赖中存在不稳定的对象/函数 useMemo / useCallback / 移到 effect 内部
无限重渲染 依赖中的对象/数组每次渲染重新创建 useMemo 或移到外部作用域
状态变化未触发重渲染 直接修改 始终替换,绝不修改
请求竞态条件 没有清理 AbortController + 清理函数
Hooks 顺序错误 条件调用 hook 始终无条件调用 hooks

→ 使用 JSON Viewer 检查数据结构来调试组件状态。