Marlon Castillo Logo

#AQuickRantAbout

Rethinking Pagination

Published June 14, 2023

MarlonAvatar

by Marlon

Is there something to rethink?

While building this portfolio using Sanity, I had to get used to their Graph-Relational Object Queries (GROQ). To me, this was a departure from database-oriented queries, typically done uding SQL.

For the most part, GROQ is straightforward: you use filters denoted by brackets [] to filter out any of your "documents" that you don't need. You can also project specific fields of the data that you need using curly brackets {}. One really cool feature is how simple it is to de-reference object relationships using an arrow ->. If you have a set of results, you can splice the result set using array notation.

GROQ
      *[_type == 'project'][0..4]
*[_type == 'project'][0...4]
    

In the above examples, line 1 returns the first 5 results (including index 4), and line 2 returns the first 4 results (excluding index 4).

When it came time build pagination, such as the one I used for the projects page, I figured it would just be a matter of going through portions of the result set, since GROQ does not natively paginate the results.

It turns out, those types of queries are inefficient. Sanity has a whole section in their documentation on how to efficiently paginate and it involves filtering. This makes sense when you understand that a query using * returns all of your data at once.

Giving up page numbers

The first decision I made in light of this is to give up pagination using numbered result pages. Numbered page results aren't my favorite of pagination practices, but my dislike is greater for "infinite" pagination. One of the reasons I don't like that solution is that it impedes access to a page's footer, which sometimes holds useful information.

I landed on a "next page" and "previous page" mechanism which I think is a successfully elegant solution to my pagination dilemma.

So then, how to implement it?

Returning sets using GROQ

In my case, I'm paginating in sets of 4, but since I'm trying to limit the number of HTTP requests made, I'm also querying for initial page data, like the title and SEO data, along with the first set of results. My initial query looks something like this:

GROQ
      {
  'page': *[_type == 'page' && slug.current == "${route.name}"][0]{
    title,seoTitle,seoDescription,seoImage
  },
  'currentProjects': *[
    _type == 'project' &&
    !(_id in path("drafts.**"))
    ]|order(publishedAt desc)[0...${pageSize}]{
    _id,publishedAt,slug,title,link,excerpt,mainImage,
  },
  'totalProjects': count(*[_type == 'project' && !(_id in path("drafts.**"))]),
}
    

The outer curly brackets are a projection. I'm telling Sanity to send me back data in object notation with a page, currentProjects, and totalProjects attributes. Each one of those has their own query, or sub-query if you will.

  • the page query returns the first document (notice the use of [0]) of page type whose slug matches the current route's name (/projects in my case), and return only the title, seoTitle, seoDescription, and seoImage fields
  • the currentProjects query returns the first 4 project documents (pageSize is defined outside of this block) that are published (not in "drafts"), sort them by publish date in descending order, and get me only the fields inside the curly brackets
  • the totalProjects query uses the GROQ function count() to return the total number of results

Subsequent queries

My pagination component is isolated so it accepts a boolean property to know whether the results are loading (disabling the buttons), the total number of pages (to know whether the "next" button is visible), the current page (to assist the previous calculation), and a function to trigger on each button's click.

Each of these functions is defined in the parent page component, which is already tracking the previous set of results. This helps me because the query for the second page of my result set needs to reference the last element in the previous query. I'm paginating based on the publish date of my projects, so the query would look something like this:

GROQ
      *[
  _type == 'project' &&
  !(_id in path("drafts.**")) &&
  (
    dateTime(publishedAt) < dateTime("${timestamp}") ||
    (
      publishedAt == "${timestamp}" &&
      _id < "${id}"
    )
  )
]|order(publishedAt desc) [0...${pageSize}]{
  _id,publishedAt, slug, title, link, excerpt, mainImage
}
    

I'm asking for the projects whose publish date is less than (older) than the current timestamp for the last object in my result set. I'm also using a tiebreaker to avoid skipping results accidentally.

To "go back" a page, I just modify the < operators to be >, and just refactor my code because D.R.Y. The final functions look like this:

index.vue
      const fetchAndUpdate = async (dir: string) => {
    if (!dir || !projects.value) {
      return;
    }

    paginationLoading.value = true;
    const timestamp = dir === 'newer' ?
      projects.value[0].publishedAt :
      projects.value[projects.value.length - 1].publishedAt;
    const id = dir === 'newer' ?
      projects.value[0]._id :
      projects.value[projects.value.length - 1]._id;
    const query = groq`*[
      _type == 'project' &&
      !(_id in path("drafts.**")) &&
      (
        dateTime(publishedAt) ${dir === 'newer' ? '>' : '<'} dateTime("${timestamp}") ||
        (
          publishedAt == "${timestamp}" &&
          _id ${dir === 'newer' ? '>' : '<'} "${id}"
        )
      )]|order(publishedAt ${dir === 'newer' ? 'asc' : 'desc'}) [0...${pageSize}]{
      _id,publishedAt, slug, title, link, excerpt, mainImage{
        alt,caption,'assetId': asset._ref,
      }}`;

    updatePagination(query, dir === 'newer');

    if (dir === 'newer') {
      page.value--;
    } else {
      page.value++;
    }

    paginationLoading.value = false;
  };

  const getOlderProjects = (e: MouseEvent) => {
    e.preventDefault();
    fetchAndUpdate('older');
  };

  const getNewerProjects = (e: MouseEvent) => {
    e.preventDefault();
    fetchAndUpdate('newer');
  };
    

Here, getOlderProjects and getNewerProjects are passed down to the pagination component to trigger on click of the respective buttons. The projects, page, and paginationLoading variables a vue reactive state to keep track of the result set, the current page number, and whether results are loading respectively.

In conclusion...

I had originally set out to have as few HTTP requests to Sanity's REST API as possible. I had planned to leverage Nuxt's useAsyncData with the watch option to automatically refresh the results each time the page number changed.

Because the initial query and subsequent queries are different, however, I had to use this approach which still means minimal requests to the server.