Zustand 与 React 19 的无限滚动:异步陷阱
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.includes,Set 的方式可扩展性更好。
陷阱 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。