Pagination and Infinite Queries with React Query
Unlock the secrets of efficient data handling with React Query. In this post we learn how to implement pagination for controlled data loading and infinite queries for that endless scroll experience, all while leveraging React Query's smart caching and state management to create a smooth, engaging user interface.
Pagination
I believe we all have worked on paginated API before, typically we will pass page
and page_size
as API request params. To demonstrate, let set default page_size=10
, and we'll keep track the page
as React state.
Our Pokémon API response will look like this:
{
totalElements: 24,
totalPages: 3,
data: Array[10]
}
Let's enhance the Pokémon search app that we have done in this .
function App() {
const [ search, setSearch ] = useState('');
const [ page, setPage ] = useState(1);
const searchHandle = (value: string) => {
setSearch(value)
setPage(1)
}
return (
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools />
<div className="container flex flex-col gap-4 mx-auto items-center">
<Input type="text" placeholder="Search pokemon" value={search} onChange={(e) => searchHandle(e.target.value)} />
<PokemonList search={search} page={page} setPage={setPage} />
</div>
</QueryClientProvider>
)
}
In PokemonList
component, pass the page
prop from parent component to usePokemons
so we can use it to fetch a chunk of data.
function usePokemons(search: string, page: number) {
return useQuery({
queryKey: ['pokemons', { search, page }],
queryFn: () => fetchPokemons(search, page),
enabled: !!search,
})
}
We also add a paginator component so user can navigate between page. FYI, all the UI components we've used in this example like input
, button
, card
, pagination
are come from .
The PokemonList
component will looks like this:
function PokemonList({
search,
page,
setPage
}: {
search: string,
page: number,
setPage: (page: number | ((page: number) => number)) => void,
}) {
const { data, status, isLoading } = usePokemons(search, page);
if (isLoading) {
return (
// render loading component
)
}
if (status === "error") {
return (
// render error component
)
}
if (status === "success") {
const { totalPages, data: pokemonList } = data;
return (
<div className="flex flex-col gap-2 items-center w-full">
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-4 w-full">
{pokemonList.map((pokemon) => (
<PokemonCard key={pokemon.id} pokemon={pokemon} />
)
)}
</div>
<Paginator currentPage={page} totalPages={totalPages} setPage={setPage} />
</div>
)
}
return (
<div>
Try to search pokemon!
</div>
)
}
function Paginator({ currentPage, totalPages, setPage }: {
currentPage: number,
totalPages: number,
setPage: (page: number | ((page: number) => number)) => void
}) {
return (
<Pagination>
<PaginationContent>
<PaginationItem className="hover:cursor-pointer">
<PaginationPrevious onClick={() => setPage(p => p - 1)}
disabled={currentPage === 1}
/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => {
return (
<PaginationItem key={p} className="hover:cursor-pointer">
<PaginationLink isActive={p === currentPage} onClick={() => setPage(p)}>{p}</PaginationLink>
</PaginationItem>
)
})}
<PaginationItem className="hover:cursor-pointer">
<PaginationNext onClick={() => setPage(p => p + 1)}
disabled={currentPage === totalPages}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)
}
We only need to keep track page
prop and pass it to useQuery
. But we can easily spot that when user switch to another page, the loading indicator show and wipe out the entire list.
What if we keep the old list visible until the new one's ready? This would reduce layout shifts and make the experience feel smoother.
Here's how we can do that with placeholderData
:
function usePokemons(search: string, page: number) {
return useQuery({
queryKey: ['pokemons', { search, page }],
queryFn: () => fetchPokemons(search, page),
enabled: !!search,
placeholderData: (previousData) => previousData
})
}
By using placeholderData
, we're telling React Query to show the last fetched data (i.e., the previous page) while it's fetching the new one. This keeps the user's context, making page changes feel more seamless.
useQuery
also exposes isPlaceholderData
property to tell us what data the query is currently providing.
We then pass it to Paginator
component so we can disabled the buttons when new data is being fetching and to PokemonCard
component so we can show a placeholder while content is loading.
function PokemonList({
search,
page,
setPage
}: {
search: string,
page: number,
setPage: (page: number | ((page: number) => number)) => void,
}) {
const { data, status, isLoading, isPlaceholderData } = usePokemons(search, page);
if (isLoading) {
return (
// render loading component
)
}
if (status === "error") {
return (
// render error component
)
}
if (status === "success") {
const { totalPages, data: pokemonList } = data;
return (
<div className="flex flex-col gap-2 items-center w-full">
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-4 w-full">
{pokemonList.map((pokemon) => (
<PokemonCard key={pokemon.id} pokemon={pokemon} isPlaceholderData={isPlaceholderData} />
)
)}
</div>
<Paginator currentPage={page} totalPages={totalPages} setPage={setPage} isPlaceholderData={isPlaceholderData} />
</div>
)
}
return (
<div>
Try to search pokemon!
</div>
)
}
function Paginator({ currentPage, totalPages, isPlaceholderData, setPage }: {
currentPage: number,
totalPages: number,
isPlaceholderData: boolean,
setPage: (page: number | ((page: number) => number)) => void
}) {
return (
<Pagination>
<PaginationContent>
<PaginationItem className="hover:cursor-pointer">
<PaginationPrevious onClick={() => setPage(p => p - 1)}
disabled={currentPage === 1 || isPlaceholderData}
/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => {
return (
<PaginationItem key={p} className="hover:cursor-pointer">
<PaginationLink isActive={p === currentPage} onClick={() => setPage(p)} disabled={isPlaceholderData}>{p}</PaginationLink>
</PaginationItem>
)
})}
<PaginationItem className="hover:cursor-pointer">
<PaginationNext onClick={() => setPage(p => p + 1)}
disabled={currentPage === totalPages || isPlaceholderData}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)
}
And here we get
We've got ourselves a fully paginated feature. But we can do it even better, what if we were always one step ahead, fetching the next page in the background? That way, when users hit "Next" page, the data's already there, and the UI pops up instantly.
Here's how we'd do that:
prefetching
First, we need to extract the query options into a reusable function.
Then in our custom hook usePokemons
we prefetch the data for next page.
function fetchPokemonsOptions(search: string, page: number) {
return queryOptions({
queryKey: ['pokemons', { search, page }],
queryFn: () => fetchPokemons(search, page),
enabled: !!search,
})
}
function usePokemons(search: string, page: number) {
const queryClient = useQueryClient()
// prefetch next page
React.useEffect(() => {
queryClient.prefetchQuery(fetchPokemonsOptions(search, page + 1))
}, [search, page, queryClient])
return useQuery({
...fetchPokemonsOptions(search, page),
placeholderData: (previousData) => previousData
})
}
With this setup, every time you turn a page, the next one's already waiting in the cache. Now that's an experience that's not just good, it's next-level smooth!
Infinite queries
Infinite scrolling has become an ubiquitous feature in modern web applications. With React Query, implementing infinite lists is not only simple but also optimized for performance.
In traditional pagination, we manage page
directly in our state and pass them to queryKey
. However, with infinite lists, we want a single cache entry that grows as we load more data. This is where useInfiniteQuery
comes into play.
Forget managing page
numbers with React state, useInfiniteQuery
does it for you. It starts from an initialPageParam
(let's say 1) and keeps fetching until we tell it to stop.
function usePokemons(search: string) {
return useInfiniteQuery({
queryKey: ['pokemons-infinite-scroll', search],
queryFn: ({ pageParam }) => fetchPokemons(search, pageParam),
initialPageParam: 1,
enabled: !!search,
})
}
When we need more data, useInfiniteQuery
provides a fetchNextPage
function. This function uses getNextPageParam
to figure out what to fetch next.
function usePokemons(search: string) {
return useInfiniteQuery({
queryKey: ['pokemons-infinite-scroll', search],
queryFn: ({ pageParam }) => fetchPokemons(search, pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
if (lastPage.totalPages === allPages.length) {
return undefined
}
return lastPageParam + 1
},
enabled: !!search,
})
}
The getNextPageParam
method have three arguments, lastPage
, allPages
, and lastPageParam
.
lastPage
is the data from the most recently fetched page.allPages
is an array containing all the pages that have been fetched up to this point.lastPageParam
is thepageParam
that was used to fetch the last page.
As we know our Pokémon API response look like this:
{
totalElements: 24,
totalPages: 3,
data: Array[10]
}
So to tell React Query there is no more data we just simply return undefined
, then the scrolling stops:
if (lastPage.totalPages === allPages.length) {
return undefined
}
Alright, so we've learned how to stash data in the cache using useInfiniteQuery
, but how do we retrieve it?
Here's where useInfiniteQuery
differs from useQuery
in a big way - the data structure.
With useQuery
, we directly get the data associated with the queryKey
. However, useInfiniteQuery
returns a data object looks like this:
{
"data": {
"pages": [
{ totalElements: 24, totalPages: 3, data: Array[10] }, /* response from API with page 1 */
{ totalElements: 24, totalPages: 3, data: Array[10] }, /* response from API with page 2 */
{ totalElements: 24, totalPages: 3, data: Array[4] } /* response from API with page 3 */
],
"pageParams": [1, 2, 3]
}
}
To be able to flatten the data of pages, we can use Array.flat()
const { data, status, isLoading } = usePokemons(search);
const pokemons = data.pages.map(p => p.data).flat();
In our pagination example, managing pages was as simple as incrementing a state variable with a paginator
component.
But with useInfiniteQuery
, the page management is taken care of for us. We get a fetchNextPage
function instead, which works by calling getNextPageParam
to determine the next pageParam
and then executing the queryFn
.
The magic here is straightforward - no new React Query concepts needed. we just need to trigger fetchNextPage
when the user hits the bottom of your list.
For this, we'll tap into the useIntersectionObserver
from useHooks
import { useIntersectionObserver } from "@uidotdev/usehooks";
const [ref, entry] = useIntersectionObserver({
threshold: 0,
root: null,
rootMargin: "0px",
});
When the element with the ref
enters the viewport, entry.isIntersecting
flips to true.
function PokemonList({
search,
}: {
search: string,
}) {
const [ref, entry] = useIntersectionObserver({
threshold: 0,
root: null,
rootMargin: "0px",
});
const { data, status, isLoading, fetchNextPage, isFetchingNextPage, hasNextPage } = usePokemons(search);
React.useEffect(() => {
if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}, [entry?.isIntersecting, hasNextPage, isFetchingNextPage])
if (isLoading) {
return (
// loading component
)
}
if (status === "error") {
return (
// error component
)
}
if (status === "success") {
const pokemons = data.pages.map(p => p.data).flat();
return (
<>
<div className="flex flex-col gap-2 items-center">
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-4 w-full">
{pokemons.map((pokemon) => {
return (
<PokemonCard key={pokemon.id} pokemon={pokemon} />
)
})}
</div>
{isFetchingNextPage && (<div className="flex justify-center">
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
</div>
)}
</div>
{hasNextPage && <div className="h-1" ref={ref} />}
</>
)
}
return (
<div>
No Pokemons founded!
</div>
)
}
Above we leverage some flags from useInfiniteQuery
:
isFetchingNextPage
: This flag goestrue
during the fetch of the next page, helping to indicate loading.hasNextPage
: This becomestrue
when there's more content to load, which is figured out by checking ifgetNextPageParam
returns undefined.
Conclusion
We've journeyed through the realms of pagination and infinite queries with React Query, and what a trip it's been! With useQuery
for traditional pagination and useInfiniteQuery
for that endless scroll feel, we've seen how React Query simplifies the management of large datasets, ensuring your app remains performance and responsive.
And again you can find the example .