Infinite Scroll with Zustand and React 19: Async Pitfalls

Published: (December 14, 2025 at 11:01 PM EST)
4 min read
Source: 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.

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.

Back to Blog

Related posts

Read more »

Advanced Lists & Pagination in SwiftUI

Lists look simple — until you try to build a real feed. Then you hit problems like: - infinite scrolling glitches - duplicate rows - pagination triggering too o...

React Wrapper for Google Drive Picker

Overview I’ve published a new package, @googleworkspace/drive-picker-react, to make it easier to use the Google Drive Picker in React applications. As the crea...