Skip to main content

Implementing Offset Pagination in React Apps

Implementing offset pagination in production requires more than tracking a page number: you need URL synchronization, error handling, loading states, and smart caching. This article walks through building a complete offset-paginated component using React Query, which handles cache invalidation and background refetching automatically.

Offset pagination is the most widely used pattern in REST APIs because it is stateless and straightforward to implement on both client and server. However, it requires discipline to avoid common pitfalls like race conditions and stale data.

Setting Up React Query for Pagination

React Query (Tanstack Query) abstracts pagination logic into simple hooks. Install it first:

npm install @tanstack/react-query @tanstack/react-query-devtools

Then wrap your app with QueryClientProvider:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

export default function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
);
}

Building a Paginated List with useQuery

Use the useQuery hook with the page number as a dependency. React Query will cache each page separately and refetch when the page changes:

import { useQuery } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';

async function fetchProducts(pageNumber, pageSize = 20) {
const offset = (pageNumber - 1) * pageSize;
const response = await fetch(
`/api/products?offset=${offset}&limit=${pageSize}`
);
if (!response.ok) throw new Error('Failed to fetch products');
return response.json();
}

export function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
const pageNumber = parseInt(searchParams.get('page') || '1', 10);
const pageSize = 20;

const {
data = { items: [], total: 0 },
isLoading,
error,
} = useQuery({
queryKey: ['products', pageNumber, pageSize],
queryFn: () => fetchProducts(pageNumber, pageSize),
staleTime: 5 * 60 * 1000, // 5 minutes
});

const totalPages = Math.ceil(data.total / pageSize);

const goToPage = (newPage) => {
setSearchParams({ page: newPage.toString() });
};

if (error) return `<div>Error: {error.message}</div>`;

return (
`<div>`
`<h2>`Products (Page {pageNumber} of {totalPages})`</h2>`
{isLoading ? (
`<p>`Loading...`</p>`
) : (
`<ul>`
{data.items.map((product) => (
`<li key={product.id}>{product.name} - ${product.price}`</li>`
))}
`</ul>`
)}
`<div style={{ marginTop: '1rem' }}>`
`<button`
onClick={() => goToPage(Math.max(1, pageNumber - 1))}
disabled={pageNumber === 1 || isLoading}
`>`
Previous
`</button>`
`<span style={{ margin: '0 1rem' }}>`
{pageNumber} of {totalPages}
`</span>`
`<button`
onClick={() => goToPage(Math.min(totalPages, pageNumber + 1))}
disabled={pageNumber === totalPages || isLoading}
`>`
Next
`</button>`
`</div>`
`</div>`
);
}

The queryKey is crucial: ['products', pageNumber, pageSize] tells React Query to treat each page as a separate cache entry. Changing the page number invalidates the old query and fetches the new page. The staleTime option prevents unnecessary refetches within 5 minutes.

Handling Error States and Retries

React Query retries failed requests 3 times by default with exponential backoff. You can customize retry logic:

const {
data = { items: [] },
isLoading,
error,
isPreviousData,
} = useQuery({
queryKey: ['products', pageNumber],
queryFn: () => fetchProducts(pageNumber),
staleTime: 5 * 60 * 1000,
retry: (failureCount, error) => {
// Don't retry 404s or authorization errors
if (error.status === 404 || error.status === 401) return false;
return failureCount `<` 3;
},
keepPreviousData: true, // Show old page while fetching new one
});

The keepPreviousData: true option is a UX win: when navigating to page 3, you see page 2's data while page 3 loads, then it switches smoothly when ready. The isPreviousData flag lets you style stale content differently.

Syncing with URL Parameters

Always encode pagination state in the URL. This enables bookmarking, back/forward navigation, and sharing:

import { useSearchParams } from 'react-router-dom';

export function PaginatedProducts() {
const [searchParams, setSearchParams] = useSearchParams();
const pageNumber = parseInt(searchParams.get('page') || '1', 10);

const goToPage = (newPage) => {
const params = new URLSearchParams(searchParams);
params.set('page', newPage.toString());
setSearchParams(params);
};

// ... rest of component
}

This syncs the page number with the browser's address bar. Refreshing the page preserves position.

Key Takeaways

  • Always include the page number in your useQuery queryKey so React Query caches each page separately.
  • Use URL search params to persist pagination state; enables browser navigation and bookmarking.
  • Set keepPreviousData: true to show the old page while fetching the new one, avoiding empty states.
  • Disable navigation buttons while loading to prevent duplicate requests.
  • Customize the retry function to avoid retrying client errors (404, 401).

Frequently Asked Questions

Why does changing the page refetch automatically?

React Query watches the queryKey. When you change the page number, the key changes, triggering a new query. This is automatic and requires no manual refetch logic.

Should I use staleTime or cacheTime?

staleTime (now called gcTime in v5) controls how long data is considered fresh. Within staleTime, React Query returns cached data without fetching. cacheTime (now gcTime) controls how long unused cache persists; data older than cacheTime is garbage-collected.

How do I refresh the current page?

Call queryClient.invalidateQueries({ queryKey: ['products', pageNumber] }) to mark it stale and refetch.

What if my API doesn't return the total count?

You can still paginate. Fetch one extra item to detect if a next page exists: if you requested 20 and got 21, there's a next page. This is called "offset pagination with existence check."

Further Reading