Infinite Scroll with Zustand and React 19: Async Pitfalls
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.
What is Infinite Scroll?
Infinite scroll automatically loads the next batch of content when a user approaches the bottom of the page—a pattern familiar from Twitter and Instagram. Compared to traditional pagination (clicking “Next”), it offers a smoother experience, but the implementation can hide subtle bugs.
For Memoreru, my indie project, the requirements were:
- Switch between three scopes (public, team, private) plus a bookmarks view
- Maintain independent pagination state for each view
- Display initial data with SSR and load more on the client
It sounds simple, yet several issues surfaced during development.
Libraries and Architecture
Delegating Scroll Detection to a Library
I used react-infinite-scroll-component to handle scroll detection and loading state management.
} // Loading display
scrollThreshold={0.6} // Trigger at 60 % scroll
>
{items.map(item => (
))}
The library abstracts away the tedious details of IntersectionObserver and container detection, letting me focus on core features. Setting scrollThreshold to 0.6 starts loading before the user reaches the bottom, reducing perceived wait times.
Managing Multiple Scopes with Zustand
Each view (public, team, private, bookmarks) needs its own “item list,” “current page,” “has more,” and “loading” flags. Zustand provides a lightweight, boiler‑free store for this.
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’s minimal API keeps the store easy to reason about while preserving each scope’s state when users switch tabs.
Pitfall 1: Same Data Displayed Twice
Root Cause
Duplicate items appeared because the API sometimes returned items that had already been fetched (e.g., a new item added after page 1 caused the first item of page 2 to overlap with the last item of page 1).
Solution – ID‑Based Duplicate Check
Filter out duplicates before appending new items, using a Set for O(1) look‑ups.
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];
});
}, []);
The Set approach scales far better than repeatedly calling Array.includes on large lists.
Pitfall 2: Data Order Gets Scrambled
Root Cause
Fast scrolling can trigger multiple page requests whose responses arrive out of order—a classic race condition:
Page 1 request → starts
Page 2 request → starts (fast scroll)
Page 2 response → arrives first
Page 1 response → arrives later
Consequently, page 1 data gets appended after page 2 data.
Solution – Track Loading State with a Ref
React state updates are asynchronous, so a mutable ref provides a synchronous “is loading?” flag.
const loadingRef = useRef(false);
const loadMore = useCallback(async () => {
if (loadingRef.current) return; // Prevent overlapping requests
loadingRef.current = true;
try {
const newItems = await fetchNextPage();
// Append newItems safely...
} finally {
loadingRef.current = false;
}
}, []);
You can still expose a loading state via useState for UI feedback; the ref simply guards against duplicate requests.
Pitfall 3: SSR Data Disappears
Root Cause
Initial data fetched via Next.js SSR sometimes vanished after client‑side hydration because a subsequent client request returned an empty array, overwriting the pre‑rendered items.
Solution – Protect SSR Data
Track whether SSR data has been loaded and avoid overwriting it with an empty response.
const fetchData = useCallback(async () => {
const items = await fetch(apiUrl).then(res => res.json());
// Skip overwriting when SSR data exists and the new payload is empty
if (store.isSSRDataLoaded && store.items.length > 0 && items.length === 0) {
console.warn('Blocked overwriting SSR data');
return;
}
updateStore(items);
}, []);
Ideally, SSR and client requests should use identical authentication and filter parameters, but defensive checks add robustness.
React 19 Considerations
React 19 introduces more aggressive automatic batching, which can reorder state updates between local component state and Zustand stores. When you need a deterministic order, wrap the local update in flushSync.
import { flushSync } from 'react-dom';
const updateItems = useCallback((newItems) => {
let mergedItems;
flushSync(() => {
setLocalState(prev => {
mergedItems = [...prev, ...newItems];
return mergedItems;
});
});
// Now the Zustand store can safely use `mergedItems`
useStore.setState({ items: mergedItems });
}, []);
Using flushSync forces the React update to complete synchronously, preventing it from being batched after the Zustand mutation. This eliminates subtle bugs that can appear only under React 19’s new batching behavior.