BlogReactJS

React Query pagination with total count and total pages returned

Written by Codemzy on November 9th, 2023

This is how I store paginated queries when I also want users to know the total results count and total number of pages for paginated data.

👀 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.

  1. I can more easily check if the count changes
  2. 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 and cacheTime 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.