👀 If you want to jump straight to the finished code - it's at the end of this blog post!
React Query makes paginated queries a breeze - it pretty much works out of the box.
But if your paged data also returns the total results count and total number of pages, you might be wondering how to handle this in React Query. Should you keep the duplicate data on each query result? Or separate it?
I ran into a few hurdles when I wanted to introduce a total count and the total number of pages:
- You can run a separate query but it involves a separate API request and could return mismatched data
- You can keep it on each page but then different pages could have different counts as data changes
So how can we jump these hurdles? I've given a couple of things a go, and these are my (kinda) hacky solutions.
The problem with count
Let's imagine our data is photos from a photo album. Because I've used this example before in my isLoading
vs isFetching
post, and I can re-use the code!
import axios from 'axios';
import { useQuery } from '@tanstack/react-query'
import Loading from '../Loading';
import Photos from '../Photos';
import Alert from '../Alert';
import Pagination from '../Pagination';
// api call
export function fetchAlbum(id, options={}) {
let { page } = options;
return axios.get(`/api/album/${id}`, { params: { page } });
};
// album component
function Album({ id }) {
// state
const [page, setPage] = React.useState(1);
const albumQuery = useQuery({ queryKey: ["album", id, page], queryFn: () => fetchAlbum(id, { page }) });
// data is fetched
if (albumQuery.data) {
return (
<div>
{ albumQuery.isFetching && <Loading type="spinner" /> }
<h1>{albumQuery.data.title}</h1>
<Photos data={albumQuery.data.photos} />
<Pagination page={page} setPage={setPage} hasNextPage={albumQuery.data.hasNextPage} />
</div>
);
}
// error fetching
if (albumQuery.isError) {
return <Alert message={albumQuery.error.message} />
}
// loading by default if no data and no error
return <Loading message="Loading Photos" />;
};
That's all great - I have my page of data, and I know if there's a next page so my Pagination
component will show "Next" and "Previous" buttons.
But what if I also want to show a count? Like the total number of photos in the album and the number of pages available?
I think it makes sense to return this data with the fetchAlbum
request, because:
- That's when I need the data
- I want the data to stay in sync with my
albumQuery
For example, if I did a separate API call for the count, what if after getting the page of pictures, and getting the count, someone added a photo? I might have 4 pictures on the page, but a count of 5!
Let's get the count at the same time and have a server response like this:
{
photos: data: [ { img: "https://images.yourdomain.com/img-6473824326.heic", name: "IMG_5463.HEIC", date: .... }, ... ],
hasNextPage: true,
totalCount: 23,
totalPages: 3,
}
We get a page of photos in the photos
array, and other information like if there's a next page, the count, and the total number of pages in the album.
Now I can display the count like this:
// data is fetched
if (albumQuery.data) {
return (
<div>
{ albumQuery.isFetching && <Loading type="spinner" /> }
<h1>{albumQuery.data.title}</h1>
<p>${albumQuery.data.totalCount} results</p>
<Photos data={albumQuery.data.photos} />
<Pagination page={page} setPage={setPage} pages={albumQuery.data.totalPages} hasNextPage={albumQuery.data.hasNextPage} />
</div>
);
}
But - there's a problem!
Let's imagine we are on page 1 and get a count of 23
. We that there are 23 photos in the album.
Then we go to page 2
and get a count of 24
. Someone added a photo to the album. Now we display the 24 count.
But then we go back to page 1.
Now if we haven't cached the results, we'll return the loading state. But react query defaults to 5 minutes of cacheTime
, so even if we haven't extended the staleTime
, we will get a short flash of 23 back on our screen while the query re-fetches the results.
And if we have extended the staleTime
, we'll get 23
back for even longer, and our page 1 and page 2 counts are out of sync - even though the counts should match.
Invalidate other pages if the count changes
We could invalidate the other pages if the totalCount
changes. This was my first idea.
When I fetch page 2, I can check the page 1 query, and if the count is different, invalidate all the other pages for the album.
queryClient.invalidateQueries("album", id, {
type: 'inactive', // only invalidate inactive queries
refetchType: 'none' // dont refetch until needed
})
But what if we didn't get to page 2 from page 1? What if we got there from page 3 or some other page? I'd have to check the query for every page and make sure they all match.
Plus I've got all this duplicate data (totalCount
and totalPages
) on each query, which feels a bit off to me.
Separate count query (attempt 1)
I settled on having a separate count query for 2 reasons.
- I can more easily check if the count changes
- I can remove all the duplicate data
But as I previously mentioned, I didn't want it to be a separate API request because I didn't want the results and the count to be out of sync.
Instead, I want to get the page, and then put totalCount
and totalPages
into its own query, with setQueryData
.
This is what my first (not great) attempt looked like:
import { useQuery, useQueryClient } from '@tanstack/react-query';
//...
function Album({ id }) {
// ...
// get the query client
const queryClient = useQueryClient();
// call the query
const albumQuery = useQuery({
queryKey: ["album", id, page],
queryFn: () => fetchAlbum(id, { page }),
onSuccess: ({ totalCount, totalPages, ...data }) => {
// check if count changed
let prevCount = queryClient.getQueryData(["album", "count", id]);
if (prevCount && totalCount !== prevCount.totalCount) {
// count changed so invalidate
queryClient.invalidateQueries("album", id, {
type: 'inactive', // only invalidate inactive queries
refetchType: 'none' // dont refetch until needed
});
}
// set the count
queryClient.setQueryData(["album", "count", id], { totalCount, totalPages });
},
});
//...
};
But I threw this code in the bin...
It helped me know when it invalidate other page queries, but didn't solve much else.
- It didn't remove the duplicate data from the album pages
- I'll still get that flash of the old count while data refetches
And - maybe you have already figured this out - because the count
query isn't being used (no useQuery
on that one!) - it's going to be garbage collected potentially sooner than the albumQuery
- which may trigger refetches even when the count hasn't changed.
It also uses onSuccess
which I just found out is being removed from useQuery
in the next version*.
*I was worried at first, but then I realised it's not being removed from useMutation
and the arguments to remove it make a lot of sense.
Separate count query (attempt 2)
Ok, my second attempt I am pretty pleased with. Is it best practice React Query? Possibly not. Is it a little bit hacky? Yep! But it ticks all my boxes (I think!).
count
is in its own query- I can pick a longer
staleTime
andcacheTime
if the count isn't likely to change often - It removed the duplicate data
- It doesn't use the soon-to-be-deprecated
onSuccess
callback
Before I talk more about it, here's the code:
// get the query client
const queryClient = useQueryClient();
// album query
const albumQuery = useQuery({
queryKey: ["album", id, page],
queryFn: () => fetchAlbum(id, { page }).then(({ totalCount, totalPages, ...data }) => {
// count
let prevCount = queryClient.getQueryData(["album", "count", id]); // to check if count changed
if (prevCount && totalCount !== prevCount.totalCount) {
// count changed so invalidate
queryClient.invalidateQueries("album", id, {
type: 'inactive', // only invalidate inactive queries
refetchType: 'none' // dont refetch until needed
})
}
// set the count
queryClient.setQueryData(["album", "count", id], { totalCount, totalPages });
// return page data with totals removed
return data;
}),
staleTime: 10 * (60 * 1000), // 10 mins
cacheTime: 15 * (60 * 1000), // 15 mins
});
// album count
const countQuery = useQuery({
queryKey: ["album", "count", id],
queryFn: () => fetchAlbum(id, { page }).then(({ totalCount, totalPages }) => { // same query function as albumQuery
return { totalCount, totalPages }; // will set count if called
}),
enabled: false, // disabled because data is fetched in albumQuery
staleTime: 20 * (60 * 1000), // 20 mins
cacheTime: 30 * (60 * 1000), // 30 mins
});
So what's happening here?
Well, I've still got albumQuery
to fetch a page from the album. But when the data comes back, since the queryFn
is a Promise, I chain on a then()
function to return a new promise and manipulate the server response.
queryFn: () => fetchAlbum(id, { page }).then(({ totalCount, totalPages, ...data }) => {
// count
let prevCount = queryClient.getQueryData(["album", "count", id]); // to check if count changed
if (prevCount && totalCount !== prevCount.totalCount) {
// count changed so invalidate
queryClient.invalidateQueries("album", id, {
type: 'inactive', // only invalidate inactive queries
refetchType: 'none' // dont refetch until needed
})
}
// set the count
queryClient.setQueryData(["album", "count", id], { totalCount, totalPages });
// return page data with totals removed
return data;
}),
Now I can do my totalCount
check, and invalidate the other pages if there's been a change to the number of photos in the album. And I also use setQuery
to add the count data.
This is all the same as before, but it happens in the queryFn
instead of onSuccess
. The benefit of this is that it happens before the data gets put in the query. So I can just return data
and the totals are no longer stored in the albumQuery
.
Next, I add another query, and I call this one countQuery
.
// album count
const countQuery = useQuery({
queryKey: ["album", "count", id],
queryFn: () => fetchAlbum(id, { page }).then(({ totalCount, totalPages }) => { // same query function as albumQuery
return { totalCount, totalPages }; // will set count if called
}),
enabled: false, // disabled because data is fetched in albumQuery query
staleTime: 20 * (60 * 1000), // 20 mins
cacheTime: 30 * (60 * 1000), // 30 mins
});
The queryKey
is the same as the one we used with setQueryData
, and because that data has just been set, I'm using enabled: false
to prevent the query from automatically running. I don't want to make another API call - I already have the data.
But you do need a query function, so I've passed the same fetchAlbum
API call, but this time the then()
function just returns totalCount
and totalPages
since that's all we need for the count query. It shouldn't ever call this function, but it's there and correct just for the whole thing to work.
I've also extended the staleTime
and cacheTime
to ensure that the countQuery
data remains while we are using the albumQuery
, since only the albumQuery
is updating countQuery
and it won't refetch on its own.
It's working pretty well for me so far, and I feel like I'm getting two queries for the price of one API call!
import axios from 'axios';
import { useQuery, useQueryClient } from '@tanstack/react-query'
import Loading from '../Loading';
import Photos from '../Photos';
import Alert from '../Alert';
import Pagination from '../Pagination';
// api call
export function fetchAlbum(id, options={}) {
let { page } = options;
return axios.get(`/api/album/${id}`, { params: { page } });
};
// album component
function Album({ id }) {
// state
const [page, setPage] = React.useState(1);
// get the query client
const queryClient = useQueryClient();
// album query
const albumQuery = useQuery({
queryKey: ["album", id, page],
queryFn: () => fetchAlbum(id, { page }).then(({ totalCount, totalPages, ...data }) => {
// count
let prevCount = queryClient.getQueryData(["album", "count", id]); // to check if count changed
if (prevCount && totalCount !== prevCount.totalCount) {
// count changed so invalidate
queryClient.invalidateQueries("album", id, {
type: 'inactive', // only invalidate inactive queries
refetchType: 'none' // dont refetch until needed
})
}
// set the count
queryClient.setQueryData(["album", "count", id], { totalCount, totalPages });
// return page data with totals removed
return data;
}),
staleTime: 10 * (60 * 1000), // 10 mins
cacheTime: 15 * (60 * 1000), // 15 mins
});
// album count
const countQuery = useQuery({
queryKey: ["album", "count", id],
queryFn: () => fetchAlbum(id, { page }).then(({ totalCount, totalPages }) => { // same query function as albumQuery
return { totalCount, totalPages }; // will set count if called
}),
enabled: false, // disabled because data is fetched in albumQuery query
staleTime: 20 * (60 * 1000), // 20 mins
cacheTime: 30 * (60 * 1000), // 30 mins
});
// data is fetched
if (albumQuery.data && countQuery.data) {
return (
<div>
{ albumQuery.isFetching && <Loading type="spinner" /> }
<h1>{albumQuery.data.title}</h1>
<p>${countQuery.data.totalCount} results</p>
<Photos data={albumQuery.data.photos} />
<Pagination page={page} setPage={setPage} pages={countQuery.data.totalPages} hasNextPage={albumQuery.data.hasNextPage} />
</div>
);
}
// error fetching
if (albumQuery.isError) {
return <Alert message={albumQuery.error.message} />
} else if (countQuery.isError) {
return <Alert message={countQuery.error.message} />
}
// loading by default if no data and no error
return <Loading message="Loading Photos" />;
};
export default Album;
Here's a bit more information on how I handle pagination on the server with Node.js and MongoDB, and the code for my React Pagination
component.