Advanced React Query: Pagination, Infinite Scroll & Prefetching



React Query is a powerful data-fetching library, but its true potential shines when you master advanced patterns for pagination, infinite scrolling, and prefetching. These techniques can transform a sluggish data-heavy app into a snappy, responsive experience.
Understanding Pagination: Offset vs Cursor
Before diving into implementation, let's understand the two main pagination strategies.
Offset Pagination
Offset pagination works like pages in a book - skip X items and take Y items.
// API: /api/posts?offset=0&limit=10
{
data: [/* 10 posts */],
total: 150,
offset: 0,
limit: 10
}
Pros:
- Simple to implement
- Easy to jump to specific pages
- Users can bookmark page URLs
Cons:
- Performance degrades with large offsets (database must scan all skipped rows)
- Can show duplicates or skip items if data changes between requests
- Not ideal for real-time data
When to use: Admin dashboards, search results, archives where data is relatively static.
Cursor Pagination
Cursor pagination uses a pointer to the last seen item, like a bookmark.
// API: /api/posts?cursor=eyJpZCI6MTIzfQ&limit=10
{
data: [/* 10 posts */],
nextCursor: "eyJpZCI6MTMzfQ",
hasMore: true
}
Pros:
- Consistent performance regardless of position in dataset
- No duplicates or skipped items when data changes
- Perfect for real-time feeds
- More efficient database queries (WHERE id > cursor)
Cons:
- Can't jump to arbitrary pages
- Harder to implement page numbers
- Can't easily share deep links to pages
When to use: Social feeds, infinite scroll, real-time data, large datasets.
Implementing Offset Pagination
Let's build a paginated table with React Query:
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
interface Post {
id: number;
title: string;
author: string;
}
interface PaginatedResponse {
data: Post[];
total: number;
offset: number;
limit: number;
}
async function fetchPosts(offset: number, limit: number): Promise<PaginatedResponse> {
const response = await fetch(`/api/posts?offset=${offset}&limit=${limit}`);
if (!response.ok) throw new Error('Failed to fetch posts');
return response.json();
}
function PostsTable() {
const [page, setPage] = useState(0);
const limit = 10;
const { data, isLoading, isError } = useQuery({
queryKey: ['posts', { page, limit }],
queryFn: () => fetchPosts(page * limit, limit),
});
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error loading posts</div>;
const totalPages = Math.ceil(data.total / limit);
return (
<div>
<table>
<thead>
<tr>
<th>Title</th>
<th>Author</th>
</tr>
</thead>
<tbody>
{data.data.map((post) => (
<tr key={post.id}>
<td>{post.title}</td>
<td>{post.author}</td>
</tr>
))}
</tbody>
</table>
<div>
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
>
Previous
</button>
<span>Page {page + 1} of {totalPages}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={page >= totalPages - 1}
>
Next
</button>
</div>
</div>
);
}
Adding Prefetching for Instant Navigation
The key to making pagination feel instant is prefetching the next page before the user clicks:
function PostsTable() {
const [page, setPage] = useState(0);
const limit = 10;
const queryClient = useQueryClient();
const { data, isLoading, isError } = useQuery({
queryKey: ['posts', { page, limit }],
queryFn: () => fetchPosts(page * limit, limit),
});
// Prefetch next page
const prefetchNextPage = () => {
const nextPage = page + 1;
queryClient.prefetchQuery({
queryKey: ['posts', { page: nextPage, limit }],
queryFn: () => fetchPosts(nextPage * limit, limit),
});
};
// Prefetch on mount and when page changes
React.useEffect(() => {
if (data && page < Math.ceil(data.total / limit) - 1) {
prefetchNextPage();
}
}, [page, data]);
// ... rest of component
}
Advanced prefetching strategy - prefetch on hover:
function PostsTable() {
// ... previous code
const handleNextHover = () => {
const nextPage = page + 1;
const totalPages = Math.ceil(data.total / limit);
if (nextPage < totalPages) {
queryClient.prefetchQuery({
queryKey: ['posts', { page: nextPage, limit }],
queryFn: () => fetchPosts(nextPage * limit, limit),
});
}
};
return (
<div>
{/* ... table */}
<button
onMouseEnter={handleNextHover}
onClick={() => setPage(p => p + 1)}
>
Next
</button>
</div>
);
}
This prefetches when the user hovers over the "Next" button, making the transition feel instantaneous while avoiding unnecessary network requests.
Implementing Cursor-Based Infinite Scroll
For infinite scrolling feeds, React Query provides useInfiniteQuery:
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
interface CursorResponse {
data: Post[];
nextCursor: string | null;
hasMore: boolean;
}
async function fetchPostsCursor(cursor: string | null, limit: number): Promise<CursorResponse> {
const url = cursor
? `/api/posts?cursor=${cursor}&limit=${limit}`
: `/api/posts?limit=${limit}`;
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch posts');
return response.json();
}
function InfinitePostsFeed() {
const { ref, inView } = useInView();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
} = useInfiniteQuery({
queryKey: ['posts-infinite'],
queryFn: ({ pageParam }) => fetchPostsCursor(pageParam, 10),
initialPageParam: null,
getNextPageParam: (lastPage) => {
return lastPage.hasMore ? lastPage.nextCursor : undefined;
},
});
// Auto-fetch when scrolling near bottom
React.useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error loading posts</div>;
return (
<div>
{data.pages.map((page, i) => (
<div key={i}>
{page.data.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>by {post.author}</p>
</article>
))}
</div>
))}
{/* Sentinel element to trigger loading */}
<div ref={ref}>
{isFetchingNextPage && <div>Loading more...</div>}
</div>
{!hasNextPage && <div>No more posts</div>}
</div>
);
}
Understanding useInfiniteQuery
The key differences from useQuery:
- pageParam: Tracks the cursor for the next page
- getNextPageParam: Determines what cursor to use for the next fetch
- data.pages: Array of all fetched pages
- fetchNextPage(): Function to load the next page
Intelligent Prefetching for Infinite Scroll
Prefetch the next page before the user reaches the bottom:
function InfinitePostsFeed() {
const { ref, inView } = useInView({
threshold: 0,
rootMargin: '400px', // Trigger 400px before reaching the element
});
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts-infinite'],
queryFn: ({ pageParam }) => fetchPostsCursor(pageParam, 10),
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
React.useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage]);
// ... rest of component
}
By setting rootMargin: '400px', the next page loads when the user is 400px away from the sentinel, creating seamless scrolling.
Optimistic Updates
Optimistic updates make your app feel instant by updating the UI before the server responds.
Optimistic Update for New Post
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface CreatePostInput {
title: string;
content: string;
}
function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newPost: CreatePostInput) => {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
});
if (!response.ok) throw new Error('Failed to create post');
return response.json();
},
// Optimistically update the cache before mutation runs
onMutate: async (newPost) => {
// Cancel outgoing refetches to avoid overwriting optimistic update
await queryClient.cancelQueries({ queryKey: ['posts-infinite'] });
// Snapshot previous value for rollback
const previousPosts = queryClient.getQueryData(['posts-infinite']);
// Optimistically update cache
queryClient.setQueryData(['posts-infinite'], (old: any) => {
if (!old) return old;
const optimisticPost = {
id: Date.now(), // Temporary ID
...newPost,
author: 'You',
};
return {
...old,
pages: [
{
data: [optimisticPost, ...old.pages[0].data],
nextCursor: old.pages[0].nextCursor,
hasMore: old.pages[0].hasMore,
},
...old.pages.slice(1),
],
};
});
return { previousPosts };
},
// Rollback on error
onError: (err, newPost, context) => {
queryClient.setQueryData(['posts-infinite'], context?.previousPosts);
},
// Refetch after success
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['posts-infinite'] });
},
});
}
function CreatePostForm() {
const createPost = useCreatePost();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createPost.mutate({
title: formData.get('title') as string,
content: formData.get('content') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
Optimistic Delete
function useDeletePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (postId: number) => {
const response = await fetch(`/api/posts/${postId}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete post');
},
onMutate: async (postId) => {
await queryClient.cancelQueries({ queryKey: ['posts-infinite'] });
const previousPosts = queryClient.getQueryData(['posts-infinite']);
// Remove post from cache
queryClient.setQueryData(['posts-infinite'], (old: any) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page: any) => ({
...page,
data: page.data.filter((post: Post) => post.id !== postId),
})),
};
});
return { previousPosts };
},
onError: (err, postId, context) => {
queryClient.setQueryData(['posts-infinite'], context?.previousPosts);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['posts-infinite'] });
},
});
}
Advanced Prefetching Strategies
1. Prefetch on Route Change
Prefetch data before navigating to a new page:
import { useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
function PostLink({ postId }: { postId: number }) {
const queryClient = useQueryClient();
const prefetchPost = () => {
queryClient.prefetchQuery({
queryKey: ['post', postId],
queryFn: () => fetchPostById(postId),
staleTime: 60000, // Consider fresh for 1 minute
});
};
return (
<Link
to={`/posts/${postId}`}
onMouseEnter={prefetchPost}
onFocus={prefetchPost}
>
View Post
</Link>
);
}
2. Prefetch Related Data
When fetching a post, prefetch the author's profile:
function usePost(postId: number) {
const queryClient = useQueryClient();
return useQuery({
queryKey: ['post', postId],
queryFn: async () => {
const post = await fetchPostById(postId);
// Prefetch author profile
queryClient.prefetchQuery({
queryKey: ['author', post.authorId],
queryFn: () => fetchAuthor(post.authorId),
});
return post;
},
});
}
3. Background Refetching
Keep data fresh in the background:
function usePostsWithBackgroundRefetch() {
return useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 30000, // Consider fresh for 30 seconds
refetchInterval: 60000, // Refetch every minute
refetchIntervalInBackground: true, // Continue when tab is inactive
});
}
4. Optimistic Rendering with Placeholder Data
Show stale data immediately while refetching:
function PostDetail({ postId }: { postId: number }) {
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: ['post', postId],
queryFn: () => fetchPostById(postId),
placeholderData: () => {
// Try to find post in the infinite scroll cache
const cachedPages = queryClient.getQueryData(['posts-infinite']) as any;
if (cachedPages) {
for (const page of cachedPages.pages) {
const post = page.data.find((p: Post) => p.id === postId);
if (post) return post;
}
}
},
});
// Renders immediately with cached data (if available) while refetching
return <div>{data?.title}</div>;
}
Performance Best Practices
1. Use Structural Sharing
React Query automatically uses structural sharing to prevent unnecessary re-renders:
// React Query only updates changed parts of the data
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
// structuralSharing: true is the default
});
2. Select Specific Data
Only subscribe to the data you need:
function PostTitles() {
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
select: (data) => data.map(post => ({ id: post.id, title: post.title })),
});
// Component only re-renders when titles change, not other post properties
}
3. Disable Queries Conditionally
Don't fetch when data isn't needed:
function PostDetail({ postId }: { postId: number | null }) {
const { data } = useQuery({
queryKey: ['post', postId],
queryFn: () => fetchPostById(postId!),
enabled: !!postId, // Only fetch when postId exists
});
}
4. Stale While Revalidate
Show cached data immediately while refetching:
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 60000, // Don't refetch for 1 minute
gcTime: 300000, // Keep unused data in cache for 5 minutes (formerly cacheTime)
});
Putting It All Together: Real-World Example
Here's a complete infinite scroll feed with all the optimizations:
function OptimizedPostsFeed() {
const { ref, inView } = useInView({ rootMargin: '400px' });
const queryClient = useQueryClient();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['posts-infinite'],
queryFn: ({ pageParam }) => fetchPostsCursor(pageParam, 20),
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
staleTime: 60000,
});
const createPost = useCreatePost();
const deletePost = useDeletePost();
// Auto-fetch next page
React.useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage]);
// Prefetch author profiles for visible posts
const prefetchAuthors = (posts: Post[]) => {
posts.forEach((post) => {
queryClient.prefetchQuery({
queryKey: ['author', post.authorId],
queryFn: () => fetchAuthor(post.authorId),
});
});
};
React.useEffect(() => {
if (data?.pages[0]?.data) {
prefetchAuthors(data.pages[0].data.slice(0, 5));
}
}, [data?.pages]);
if (isLoading) return <LoadingSkeleton />;
return (
<div>
{data.pages.map((page, i) => (
<div key={i}>
{page.data.map((post) => (
<PostCard
key={post.id}
post={post}
onDelete={() => deletePost.mutate(post.id)}
/>
))}
</div>
))}
<div ref={ref}>
{isFetchingNextPage && <LoadingSpinner />}
</div>
</div>
);
}
Conclusion
Mastering React Query's pagination, infinite scroll, and prefetching capabilities transforms user experience:
- Offset pagination for traditional page-based interfaces with smart prefetching
- Cursor pagination for infinite scroll with consistent performance
- Optimistic updates for instant feedback on mutations
- Intelligent prefetching on hover, route changes, and related data
- Performance optimizations with structural sharing, selective subscriptions, and stale-while-revalidate
These patterns create apps that feel instant and responsive, even with large datasets. The key is understanding when to use each technique and combining them thoughtfully for your specific use case.
Resources
- React Query Documentation
- TanStack Query DevTools - Essential for debugging
- react-intersection-observer - Elegant scroll detection