DEV Community

Rezaul Karim
Rezaul Karim

Posted on

Infinite Scrolling in React Native or Expo with Zustic Query

Infinite scrolling is one of the most common UX patterns in mobile apps. Instead of loading hundreds of records at once, we load data page by page as the user scrolls.

In this article, we'll build a reusable infinite scroll component in React Native using:

  • FlatList
  • Zustic API queries
  • Pagination with offset & limit
  • Auto cache updates

By the end, you will have a clean, reusable FlashList component that works with any API.


Tech Stack

Example API:

https://jsonplaceholder.typicode.com/posts
Enter fullscreen mode Exit fullscreen mode

The Idea

We separate the logic into two queries:

Initial Query

Loads the first page.

useGetPostsQuery()
Enter fullscreen mode Exit fullscreen mode

Pagination Query

Loads more data when the user scrolls.

useGetPostsByPageMutation()
Enter fullscreen mode Exit fullscreen mode

Then we merge the data into cache automatically using middleware.

This keeps UI logic extremely clean.


Install the Library

Run the following command:

npm install zustic
Enter fullscreen mode Exit fullscreen mode

or if you are using Yarn:

npm install zustic
Enter fullscreen mode Exit fullscreen mode

Step 1 — Create a Reusable Infinite Scroll Component

We create a generic component called FlashList.

It works with any API query.

import {
  ActivityIndicator,
  FlatList,
  RefreshControl,
  Text,
  View,
} from "react-native";
import React, { useRef, useState } from "react";

export default function FlashList({
  useQuery1,
  useQuery2,
  queryParams,
  selector,
  ...extra
}) {
  const { isLoading, data, reFetch } = useQuery1({ ...queryParams });

  const [getMore, res] = useQuery2();

  const page = useRef(0);
  const hasMore = useRef(true);

  const [refreshing, setRefreshing] = useState(false);

  const onRefresh = async () => {
    page.current = 0;
    hasMore.current = true;

    setRefreshing(true);
    await reFetch();
    setRefreshing(false);
  };

  const handleMore = () => {
    if (res?.isLoading || isLoading) return;

    if (!hasMore.current) return;

    if ((data?.data?.length || 0) < queryParams.limit) return;

    page.current += 1;

    getMore({
      ...queryParams,
      offset: queryParams.limit * page.current,
    });

    if ((res.data?.data?.length || 0) < queryParams.limit) {
        hasMore.current = false;
    }
  };

  return (
    <FlatList
      data={selector ? selector(data) : data?.data || []}
      keyExtractor={(item, index) => `${item.id}-${index}`}
      onEndReached={handleMore}
      refreshControl={
        <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
      }
      ListFooterComponent={
        res.isLoading ? <ActivityIndicator size="small" /> : null
      }
      ListEmptyComponent={
        isLoading ? (
          <ActivityIndicator size="large" />
        ) : (
          <Text>No Data Found</Text>
        )
      }
      {...extra}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 2 — Create Base Query (Fetch Wrapper)

This is a simple fetch wrapper that works like RTK Query baseQuery.

class Query {
  baseQuery = async (args) => {
    try {
      const response = await fetch(
        `https://jsonplaceholder.typicode.com${args.url}`
      );

      if (!response.ok) throw new Error("Request failed");

      const data = await response.json();

      return { data };
    } catch (error) {
      return {
        error: {
          status: 500,
          data: { message: error.message },
        },
      };
    }
  };
}

export default new Query();
Enter fullscreen mode Exit fullscreen mode

Step 3 — Create Zustic API

Now we define two endpoints.

One for initial fetch and one for pagination.

const api = createApi({
  baseQuery: query.baseQuery,
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: ({ limit, offset }) => ({
        url: `/posts?_limit=${limit}&_start=${offset}`,
      }),
    }),

    getPostsByPage: builder.mutation({
      query: ({ limit, offset }) => ({
        url: `/posts?_limit=${limit}&_start=${offset}`,
      }),

      middlewares: [
        async (ctx, next) => {
          const result = await next();

          api.utils.updateQueryData(
            "getPosts",
            { ...ctx.arg, offset: 0 },
            (draft) => {
              return [
                ...(draft || []),
                ...(result.data || []),
              ];
            }
          );

          return result;
        },
      ],
    }),
  }),
});
Enter fullscreen mode Exit fullscreen mode

This middleware automatically merges new pages into existing cache.

So your UI never manually manages state.


Step 4 — Use Infinite Scroll

Now using it becomes extremely simple.

import React from "react";
import { View, Text } from "react-native";
import FlashList from "./FlashList";

export default function InfinityScrollScreen() {
  return (
    <FlashList
      queryParams={{
        limit: 10,
        offset: 0,
      }}
      useQuery1={useGetPostsQuery}
      useQuery2={useGetPostsByPageMutation}
      renderItem={({ item }) => (
        <View style={{ padding: 12 }}>
          <Text>{item.title}</Text>
        </View>
      )}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Result

Now your list will:

  • Load first page
  • Load next page on scroll
  • Merge cache automatically
  • Support pull-to-refresh
  • Work with any endpoint

Why This Pattern is Powerful

Most infinite scroll implementations suffer from:

  • messy state management
  • duplicated arrays
  • pagination bugs

This pattern fixes that by:

  • letting Zustic handle caching
  • using middleware for merging
  • keeping UI completely clean

Your screens only care about:

renderItem
Enter fullscreen mode Exit fullscreen mode

Everything else happens automatically.


Bonus Improvements

You can enhance this further:

Add:

  • Prefetch next page
  • Offline caching
  • Optimistic updates
  • retry logic
  • request deduplication

Final Thoughts

This architecture makes infinite scrolling:

  • clean
  • reusable
  • scalable

Instead of implementing pagination logic in every screen, you now have a single reusable FlashList component.


Top comments (0)