You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React Hooks通用无限滚动的useEffect依赖与竞态条件问题

解决React无限滚动组件的竞态条件与依赖问题

这个场景我太熟悉了——用Intersection Observer做无限滚动时,很容易陷入「依赖项导致回调频繁重建→Effect意外触发→竞态条件」的死循环。咱们不用禁用ESLint,几个优雅的方案就能搞定:

方案1:用函数式更新消除不必要的依赖(最推荐)

你父组件里的loadData依赖posts,本质是为了获取当前列表长度,但直接依赖状态变量会导致每次posts变化时回调都重新创建。改用React状态的函数式更新,就能摆脱对posts的依赖,同时还能修正你代码里直接修改状态数组的错误(直接posts.push违反了React状态不可变的原则哦)。

修改后的父组件回调:

const loadData = useCallback(async () => {
  if (!hasMore) return;

  // 模拟异步请求延迟
  await new Promise(resolve => setTimeout(resolve, 2000));
  
  // 用函数式更新获取最新的posts状态,避免直接依赖posts
  setPosts(prevPosts => {
    const startAmount = prevPosts.length;
    // 创建新数组,不修改原状态
    const newPosts = [...prevPosts];
    for (let i = 0; i < 20; ++i) {
      newPosts.push(`I am post number ${i + startAmount}.`);
    }
    return newPosts;
  });
  
  // 同样用函数式更新判断是否还有更多数据
  setHasMore(prevHasMore => {
    return prevPosts => prevPosts.length + 20 < 100;
  });
}, [hasMore]); // 现在只依赖hasMore,回调不会频繁重建了

这样loadData的依赖只有hasMore,不会因为posts变化而重新创建,子组件的Effect也就不会被意外触发了。

方案2:用useRef保存回调的最新引用(适合复杂场景)

如果你的加载逻辑必须依赖多个状态,不想重构代码,可以用useRef把最新的getMore回调存起来,子组件里只监听isLoading,调用时用ref.current获取最新的函数:

子组件修改:

import { useRef, useEffect } from 'react';

const InfiniteScroll = ({ getMore, isLoading, setIsLoading, children }) => {
  const getMoreRef = useRef();

  // 每次渲染都更新ref,保存最新的getMore
  useEffect(() => {
    getMoreRef.current = getMore;
  }, [getMore]);

  useEffect(() => {
    if (isLoading) {
      const fetchNextPage = async () => {
        await getMoreRef.current();
        setIsLoading(false);
      };
      fetchNextPage();
    }
  }, [isLoading]); // 只依赖isLoading,getMore变化不会触发这个Effect

  // 你的Intersection Observer逻辑...
  return (
    <div>
      {children}
      {isLoading && <div>加载中...</div>}
    </div>
  );
};

这个方案的核心是:useRef的current属性是可变的,不会触发组件重新渲染,我们用它来「缓存」最新的回调函数,这样即使父组件的getMore重建了,子组件的Effect也不会因为依赖变化而触发,只会在isLoading变为true时执行。

方案3:用useReducer统一状态管理(适合复杂状态逻辑)

如果你的组件状态逻辑越来越复杂(比如还要处理加载错误、分页参数等),可以用useReducer把状态更新和业务逻辑封装起来,dispatch函数是稳定的,不会随状态变化而重建:

父组件示例:

const postsReducer = (state, action) => {
  switch (action.type) {
    case 'LOAD_MORE':
      const startAmount = state.posts.length;
      const newPosts = [...state.posts];
      for (let i = 0; i < 20; ++i) {
        newPosts.push(`I am post number ${i + startAmount}.`);
      }
      return {
        ...state,
        posts: newPosts,
        hasMore: newPosts.length < 100,
      };
    default:
      return state;
  }
};

const ParentComponent = () => {
  const [state, dispatch] = useReducer(postsReducer, {
    posts: [],
    hasMore: true,
  });

  const loadData = useCallback(async () => {
    if (!state.hasMore) return;
    await new Promise(resolve => setTimeout(resolve, 2000));
    dispatch({ type: 'LOAD_MORE' });
  }, [state.hasMore, dispatch]);

  // 渲染子组件...
};

这里loadData只依赖state.hasMore和dispatch,而dispatch是React保证稳定的引用,不会导致回调频繁重建。

额外提醒:避免竞态条件的小技巧

不管用哪个方案,都要在加载函数里加个「防重复触发」的判断,比如在loadData开头检查是否正在加载:

const loadData = useCallback(async () => {
  if (!hasMore || isLoading) return; // 加个isLoading判断
  // ...加载逻辑
}, [hasMore, isLoading]);

这样即使因为某些原因触发了多次加载,也只会执行一次。

内容的提问来源于stack exchange,提问作者IceMetalPunk

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.14 08:25:10