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
- React Native
- Zustic API query system
- FlatList
- JSONPlaceholder API
Example API:
https://jsonplaceholder.typicode.com/posts
The Idea
We separate the logic into two queries:
Initial Query
Loads the first page.
useGetPostsQuery()
Pagination Query
Loads more data when the user scrolls.
useGetPostsByPageMutation()
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
or if you are using Yarn:
npm install zustic
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}
/>
);
}
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();
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;
},
],
}),
}),
});
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>
)}
/>
);
}
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
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)