Zustand 与 React 19 的无限滚动:异步陷阱

发布: (2025年12月15日 GMT+8 12:01)
6 min read
原文: Dev.to

Source: Dev.to

Day 15 of the Solo SaaS Development – Design, Implementation, and Operations Advent Calendar 2025.
Yesterday’s article covered “Mobile‑First Design.” This post explains the pitfalls I encountered when implementing infinite scroll with Zustand and React 19, and how I solved them.

什么是无限滚动?

无限滚动会在用户接近页面底部时自动加载下一批内容——这是 Twitter 和 Instagram 等平台常见的模式。相较于传统的分页(点击 “Next”),它提供了更流畅的体验,但实现过程中容易隐藏细微的 bug。

对于 Memoreru 这款我的独立项目,需求如下:

  • 在公共、团队、私有三种范围以及书签视图之间切换
  • 为每个视图维护独立的分页状态
  • 使用 SSR 渲染初始数据,并在客户端加载更多

听起来很简单,但在开发过程中出现了多个问题。

库与架构

将滚动检测委托给库

我使用 react-infinite-scroll-component 来处理滚动检测和加载状态管理。

}        // Loading display
  scrollThreshold={0.6}               // Trigger at 60 % scroll
>
  {items.map(item => (
    
  ))}

该库抽象了 IntersectionObserver 与容器检测的繁琐细节,让我可以专注于核心功能。将 scrollThreshold 设置为 0.6 可以在用户到达底部之前就开始加载,从而降低感知等待时间。

使用 Zustand 管理多范围状态

每个视图(public、team、private、bookmarks)都需要自己的“项目列表”“当前页码”“是否还有更多”“加载中”标记。Zustand 提供了一个轻量、无需额外依赖的 store 来实现这一点。

interface ContentStore {
  // Items per scope
  publicItems: ContentItem[];
  privateItems: ContentItem[];
  teamItems: ContentItem[];
  bookmarkItems: ContentItem[];

  // Pagination state (per scope)
  pagination: {
    public: { page: number; hasMore: boolean };
    private: { page: number; hasMore: boolean };
    team: { page: number; hasMore: boolean };
    bookmarks: { page: number; hasMore: boolean };
  };

  // Loading state (per scope)
  loadingState: {
    public: boolean;
    private: boolean;
    team: boolean;
    bookmarks: boolean;
  };
}

Zustand 的极简 API 让 store 易于理解,同时在用户切换标签页时仍能保留每个范围的状态。

陷阱 1:相同数据展示两次

根本原因

出现重复项是因为 API 有时会返回已经获取过的项目(例如,第 1 页后新增了一条数据,导致第 2 页的第一条与第 1 页的最后一条重叠)。

解决方案 – 基于 ID 的去重检查

在追加新项目之前先过滤掉重复项,使用 Set 实现 O(1) 查找。

const loadMoreItems = useCallback(async () => {
  const newItems = await fetchNextPage();

  setItems(prev => {
    const existingIds = new Set(prev.map(item => item.id));
    const uniqueNewItems = newItems.filter(item => !existingIds.has(item.id));
    return [...prev, ...uniqueNewItems];
  });
}, []);

相较于在大列表上反复调用 Array.includesSet 的方式可扩展性更好。

陷阱 2:数据顺序被打乱

根本原因

快速滚动会触发多个分页请求,而这些请求的响应可能会乱序返回——典型的竞争条件:

Page 1 request → starts
Page 2 request → starts (fast scroll)
Page 2 response → arrives first
Page 1 response → arrives later

于是第 1 页的数据被追加在第 2 页数据之后。

解决方案 – 用 Ref 跟踪加载状态

React 的 state 更新是异步的,使用可变的 ref 可以提供同步的 “是否正在加载?” 标记。

const loadingRef = useRef(false);

const loadMore = useCallback(async () => {
  if (loadingRef.current) return;   // 防止请求重叠
  loadingRef.current = true;

  try {
    const newItems = await fetchNextPage();
    // 安全地追加 newItems...
  } finally {
    loadingRef.current = false;
  }
}, []);

仍然可以通过 useState 暴露 loading 状态用于 UI 提示;ref 只负责阻止重复请求。

陷阱 3:SSR 数据消失

根本原因

通过 Next.js SSR 获取的初始数据有时会在客户端水合后消失,因为随后一次客户端请求返回了空数组,覆盖了预渲染的项目。

解决方案 – 保护 SSR 数据

跟踪 SSR 数据是否已加载,并在收到空响应时避免覆盖。

const fetchData = useCallback(async () => {
  const items = await fetch(apiUrl).then(res => res.json());

  // 当 SSR 数据已存在且新 payload 为空时,跳过覆盖
  if (store.isSSRDataLoaded && store.items.length > 0 && items.length === 0) {
    console.warn('Blocked overwriting SSR data');
    return;
  }

  updateStore(items);
}, []);

理想情况下,SSR 与客户端请求应使用相同的认证和过滤参数,但防御性检查可以提升鲁棒性。

React 19 的注意事项

React 19 引入了更激进的自动批处理,这可能导致本地组件状态与 Zustand store 之间的更新顺序被重新排列。当需要确定的执行顺序时,可将本地更新包装在 flushSync 中。

import { flushSync } from 'react-dom';

const updateItems = useCallback((newItems) => {
  let mergedItems;

  flushSync(() => {
    setLocalState(prev => {
      mergedItems = [...prev, ...newItems];
      return mergedItems;
    });
  });

  // 现在可以安全地在 Zustand store 中使用 `mergedItems`
  useStore.setState({ items: mergedItems });
}, []);

使用 flushSync 可以强制 React 更新同步完成,防止其在 Zustand 变更后被批处理。这消除了在 React 19 新批处理行为下才会出现的细微 bug。

Back to Blog

相关文章

阅读更多 »

SwiftUI 中的高级列表与分页

列表看起来很简单——直到你尝试构建真实的动态信息流。然后你会遇到诸如:- infinite scrolling glitches - duplicate rows - pagination triggering too o...

React 封装 Google Drive Picker

概述 我已发布一个新包 @googleworkspace/drive-picker-react,以便在 React 应用中更轻松地使用 Google Drive Picker。作为创建…