DEV Community

Gabriel Nordeborn
Gabriel Nordeborn

Posted on

Pagination with minimal effort in Relay

This series of articles is written by Gabriel Nordeborn and Sean Grove. Gabriel is a frontend developer and partner at the Swedish IT consultancy Arizon, and has been a Relay user for a long time. Sean is a co-founder of OneGraph.com, unifying 3rd-party APIs with GraphQL.

Pagination. Everyone gets there eventually, and - let’s be honest - it’s not fun. In this article we’ll show that when you follow a few conventions, pagination in Relay may not be fun, but it is easy and ergonomic.

This article will focus on simple pagination, without filters, and only paginating forward. But, Relay can paginate backwards just as easily, and handles the filter case beautifully. You can read more about those two things here.

Also, for pagination in Relay to be as sweet as it can, your GraphQL server will need to follow two specific GraphQL best practices:

  1. Global object identification and the Node interface. We also have another article about that you can read here.
  2. Connection based pagination. Again, we have a separate article you're much welcome to read here.

In this article, we’ll lay out a familiar example app first, and then walk through the challenges in implementing the required pagination. Finally, we’ll illustrate Relay’s solution to said problems.

How is pagination typically done in GraphQL clients?

Pagination usually consists of this:

  1. You fetch some form of initial list of items, usually through another query (typically the main query for the view you’re in). This query normally contains a bunch of other things in addition to items from the list you want to paginate.
  2. You define a separate query that can fetch more items for the list.
  3. You use the separate query with the appropriate cursor that you got from the first query in order to paginate forward, specifying the number of items you want
  4. Then you write code to merge the items from the first list with the new items, and re-render your view

Let’s see that in action now, with a typical example that gets all the data for a user’s profile page:

    query ProfileQuery($userLogin: String!) {
      gitHub {
        user(login: $userLogin) {
          name
          avatarUrl
          email
          following {
            totalCount
          }
          followers(first: 5) {
            totalCount
            edges {
              node {
                id
                firstName
                lastName
                avatarUrl
              }
            }
          }
        }
      }
    }

Our query pulls out two groups of data we care about:

  1. Profile information for our user, like name and email
  2. A list of followers with some fields for each one. To start with, we just get the first 5 followers.

Now that we have our first query, let’s paginate to get the next 5 followers (we have some popular users!).

Trying to re-use the original query isn't good enough

The first thing we notice is that we probably shouldn't reuse the first query we defined for pagination. We’ll need a new query, because:

  • We don’t want to fetch all of the profile information for the user again, since we already have it and fetching it again might be expensive.
  • We know we want to start off with only the first 5 followers and delegate loading more to actual pagination, so adding variables for pagination in this initial query feels redundant and would add unnecessary complexity.

So, let’s write the new query:

     query UserProfileFollowersPaginationQuery(
      $userLogin: String!, 
      $first: Int!, 
      $after: String
    ) {
      gitHub {
        user(login: $userLogin) {
          followers(first: $first, after: $after) {
            pageInfo {
              hasNextPage
              endCursor
            }
            edges {
              node {
                id
                firstName
                lastName
                avatarUrl
              }
            }
          }
        }
      }
    }

Here we go! We now have all we need to paginate. Great! But, there’s a few things to note here:

  • We need to write this query by hand
  • Even though we know what User we want to paginate followers on already, we need to give the query that information again through variables. This also needs to exactly match how our initial query is selecting the user, so we're getting the right one
  • We’ll need to manually give the query the next cursor to paginate from. Since this will always be the end cursor in this view, this is just manual labor that needs to be done

It’s a shame that we need to do all of this manual work. What if the framework could just generate this pagination query for us, and maybe deal with all the steps that will always be the same anyway…?

Well, using the node interface and connection based pagination, Relay can!

Pagination in Relay

Let’s illustrate how pagination works in Relay with a similar example to the one above - a simple profile page. The profile page lists some information about the user, and then also list the users friends. The list of friends should be possible to paginate.

    // Profile.ts
    import * as React from "react";
    import { useLazyLoadQuery } from "react-relay/hooks";
    import { graphql } from "react-relay";
    import { ProfileQuery } from "./__generated__/ProfileQuery.graphql";
    import { FriendsList } from "./FriendsList";

    interface Props {
      userId: string;
    }

    export const Profile = ({ userId }: Props) => {
      const { userById } = useLazyLoadQuery<ProfileQuery>(
        graphql`
          query ProfileQuery($userId: ID!) {
            userById(id: $userId) {
              firstName
              lastName
              ...FriendsList_user
            }
          }
        `,
        {
          variables: { userId }
        }
      );

      if (!userById) {
        return null;
      }

      return (
        <div>
          <h1>
            {userById.firstName} {userById.lastName}
          </h1>
          <h2>Friends</h2>
          <FriendsList user={userById} />
        </div>
      );
    };

Here’s our root component for showing the profile page. As you can see it makes a query, asks for some information that it’s displaying itself (firstName and lastName), and then includes the FriendsList_user fragment, which contains the data the FriendsList component need on the User type to be able to render.

The power of true modularity of components

No pagination to be seen anywhere so far though, right? Hold on, it’s coming! But, first, notice this: This component doesn’t need to know that <FriendsList /> is doing pagination. That is another strength of Relay. Let’s highlight a few implications this has:

  • Any component can introduce pagination in isolation without needing any action from components that already render it. Thinking “meh”? You won't when you have a component spread out through a fairly large number of screens that you need to introduce pagination to without it being a 2 week project.
  • ProfileQuery doesn’t need to define anything unnecessary, like variables, just to ensure that <FriendsList /> can paginate.
  • Alluding to the points above, this means that no implicit (or explicit) dependencies are created between components, which in turn means that you can safely refactor and maintain your components without risking breaking stuff. It also means you can do said things fast.

Building the component that does the pagination

Below is the FriendsList component, which is what’s actually doing the pagination. This is a bit more dense:

    // FriendsList.ts
    import * as React from "react";
    import { usePaginationFragment } from "react-relay/hooks";
    import { graphql } from "react-relay";
    import { FriendsList_user$key } from "./__generated__/FriendsList_user_graphql";
    import { FriendsListPaginationQuery } from "./__generated__/FriendsListPaginationQuery_graphql";
    import { getConnectionNodes } from "./utils/getConnectionNodes";

    interface Props {
      user: FriendsList_user$key;
    }

    export const FriendsList = ({ user }: Props) => {
      const { data, hasNext, loadNext, isLoadingNext } = usePaginationFragment<
        FriendsListPaginationQuery,
        _
      >(
        graphql`
          fragment FriendsList_user on User
            @argumentDefinitions(
              first: { type: "Int!", defaultValue: 5 }
              after: { type: "String" }
            )
            @refetchable(queryName: "FriendsListPaginationQuery") {
            friends(first: $first, after: $after)
              @connection(key: "FriendsList_user_friends") {
              edges {
                node {
                  id
                  firstName
                }
              }
            }
          }
        `,
        user
      );

      return (
        <div>
          {getConnectionNodes(data.friends).map(friend => (
            <div key={friend.id}>
              <h2>{friend.firstName}</h2>
            </div>
          ))}
          {hasNext ? (
            <button
              disabled={isLoadingNext}
              onClick={() => loadNext(5)}
            >
              {isLoadingNext ? "Loading..." : "Load more"}
            </button>
          ) : null}
        </div>
      );
    };

There’s a lot going on here, and we will break it all down momentarily, but notice how little manual work we’ve needed to do. Here’s a few things to note:

  • No need to define a custom query to use for paginating. It’s automatically generated for us by Relay.
  • No need to keep track of what’s the next cursor to paginate from. Relay does it for us, so we can’t mess that up.
  • No need for any custom logic for merging the pagination results with what’s already in the store. Relay does it for us.
  • No need to do anything extra to keep track of the loading state or if there are more items I can load. Relay supplies us with that with no additional action needed from our side.

Other than the benefit that less code is nice just by itself, there’s also the benefit of less hand rolled code meaning less things to potentially mess up.

Let’s break down everything in the code snippet above that make that possible, because there’s likely a few things in there making you scratch your head:

    import { FriendsList_user$key } from "./__generated__/FriendsList_user_graphql";
    import { FriendsListPaginationQuery } from "./__generated__/FriendsListPaginationQuery_graphql";

At the top we’re importing a bunch of type definitions from a __generated__ folder. These are to ensure type safety for both for the fragment we’re defining and the for pagination query that is automatically generated for us by the Relay compiler for each GraphQL operation we define in our project.

    import { getConnectionNodes } from "./utils/getConnectionNodes";

We also import a function called getConnectionNodes. This is a custom helper that can extract all nodes from any connection into an array in a type safe way. It’s not from the official Relay packages, but it’s very easy to make one yourself, as you can see an example of here. It’s a great example of the type of tooling you can build easily because of standardization.

      const { data, hasNext, loadNext, isLoadingNext } = usePaginationFragment<
        FriendsListPaginationQuery,
        _
      >(
        graphql`
          fragment FriendsList_user on User
            @argumentDefinitions(
              first: { type: "Int!", defaultValue: 5 }
              after: { type: "String" }
            )
            @refetchable(queryName: "FriendsListPaginationQuery") {
            friends(first: $first, after: $after)
              @connection(key: "FriendsList_user_friends") {
              edges {
                node {
                  id
                  firstName
                }
              }
            }
          }
        `,
        user
      );

We use a hook called usePaginationFragment which gives us back a bunch of props related to pagination. It also gives us data, which is the data for the FriendsList_user fragment we’re defining.

Speaking of the fragment, that’s where most of the good stuff is happening. Let’s go deeper into what’s going on in the fragment definition.

            @argumentDefinitions(
              first: { type: "Int!", defaultValue: 5 }
              after: { type: "String" }
            )

Relay let you define arguments for fragments

The first thing that stands out is that we’ve added a directive to the fragment called @argumentDefinitions, which define two arguments, first (as Int!) and after (as String). first is required, so if no argument is given to the fragment for that, Relay will use the defined default value, which in this case is 5. This is how Relay knows to fetch the first 5 followers in ProfileQuery.

The ability to define arguments for fragments is another feature of Relay that make all the difference for modularity and scalability. We won’t go deeper into exactly how this works, but this would allow any user of the FriendsList_user fragment to override the values of first and after when using that fragment. Like this:

    query SomeUserQuery {
      loggedInUser {
        ...FriendsList_user @arguments(first: 10)
      }
    }

This would fetch the first 10 followers directly in <FriendsList /> instead of just the first 5, which is the default.

Relay writes your pagination query for you

            @refetchable(queryName: "FriendsListPaginationQuery")

After that comes another directive, @refetchable. This is telling Relay that you want to be able to refetch the fragment with new variables, and queryName that’s provided to the directive says that FriendsListPaginationQuery is what you want the generated query to be called.

This would generate a query that looks roughly like this:

    query FriendsListPaginationQuery($id: ID!, $first: Int!, $after: String!) {
      node(id: $id) {
        ... on User {
          friends(first: $first, after: $after) {
            pageInfo {
              endCursor
              hasNextPage
              startCursor
              hasPreviousPage
            }
            edges {
              node {
                id
                firstName
              }
              cursor
            }
          }
        }
      }
    }

But you don’t need to know, think or care about this! Relay will take care of all the plumbing for you, like supplying all needed variables for the query (like id and after, which is the cursor to paginate from next). You only need to say how many more items you want to fetch.

This is the meat of what makes pagination so ergonomic with Relay - Relay will literally write your code and queries for you, hiding all of that complexity of pagination for you!

Let Relay know where it can find your connection, and it’ll do the rest

            friends(first: $first, after: $after)
              @connection(key: "FriendsList_user_friends") {
              edges {
                node {
                  id
                  firstName
                }
              }
            }
          }

**friends(first: $first, after: $after)**
After that comes the field selection. friends is the field with the connection we want to paginate. Notice that we’re passing that the first and after arguments defined in @argumentDefinitions.

**@connection**
Attached to friends is another directive, @connection(key: "FriendsList_user_friends"). This directive tell Relay that here’s the location of the connection you want to paginate. Adding this allow Relay to do a few things, like automatically add the full selection for pageInfo on the connection selection in the query that’s sent to the server. Relay then uses that information both to tell you whether you can load more, and to automatically use the appropriate cursor for paginating. Again, removing manual steps that can go wrong and automating them.

Again, you don’t need to see or think about this as Relay takes care of all of this, but the actual selection on friends that’s sent to the server look something like this:

    friends(first: $first, after: $after) {
      pageInfo {
        endCursor
        hasNextPage
        startCursor
        hasPreviousPage
      }
      egdes {
        node {
          ...
        }
        cursor
      }
    }      

By adding the @connection annotation, Relay knows where to add the selections it needs to know how to paginate.

The next thing @connection does is tell Relay what key you want to use if you need to interact with this connection in the cache, like when adding or removing items to the connection through cache updates. Setting a unique key here is important because you may have multiple lists paginating over the same connection at the same time.

It also means that Relay can infer the location of everything it needs to extract from the pagination response and add to the current pagination list.

            <button
              disabled={isLoadingNext}
              onClick={() => loadNext(5)}
            >

Other than that, most of the code that actually use the things Relay give us should be fairly self explanatory.

How can this work?

So, summing up what pagination looks like, you’re basically giving Relay the information it needs through directives in your fragment definition, and in return Relay automates everything it can for you.

But, how can Relay do all of this?

It all boils down to conventions and standardization. If you follow the global identification and node interface specification, Relay can:

  • Automatically generate a query to refetch the particular node we’re on, and automatically add the fragment we’re refetching to that query
  • Ensure you won’t need to supply any variables for the generated query at all, since it knows that the id for the object we’re looking at can only lead to that particular object

And, by following the connection specification for pagination, Relay can:

  • Automatically add whatever metadata selection it needs to the queries, both the initial ProfileQuery and the generated FriendsListPaginationQuery
  • Automatically merge the pagination results with the existing list, since it knows that the structure of the data is a standardized connection, and therefore it can extract whatever it needs
  • Automatically keep track of what cursor to use for loading more results, since that will be available on pageInfo in a standardized way. pageInfo which it (as mentioned above) can automatically insert into the query selection without you knowing about it. Again because it’s standardized.

And the result is really sweet. In addition to making pagination much more ergonomic, Relay has also eliminated just about every surface for manual errors we’d otherwise have.

Wrapping up

In this article, we’ve tried to highlight just how much a framework like Relay can automate for you, and how incredible the DX can be, if you follow conventions. This article has tried to shed some light on the following:

  • Pagination in GraphQL can require a lot of manual work and offer lots of surface for messing up as a developer
  • By following conventions, a framework like Relay can turn the pagination experience into something incredibly ergonomic and remove most (if not all) surfaces for manual errors

While this is a good primer, there are many more features and capabilities for pagination in Relay that we can explore. You can read all about that in Relay’s official documentation here.

Thank you for reading!

Discussion (3)

Collapse
sebastienh profile image
Sébastien Hamel • Edited on

Really great article! Not so much information about Relay and this is quite a welcome addition.

I was wondering, I am starting a new project in Relay so I am quite new, but in your example you use a connection which is a field of a type (friends of User) to demonstrate the use of usePaginationFragment hook. Is there a way to use that same mecanic outside a type's field? I am basically looking at using an independant query for pagination: the same kind of query you defined in the previous article:

    type Query {
      searchMovies(
        query: String!
        first: Int, 
        last: Int, 
        after: String, 
        before: String
      ): MovieConnection
    }
Enter fullscreen mode Exit fullscreen mode

Does Relay offer anything to directly deal with these?

Thanks again! :)

Collapse
zth profile image
Gabriel Nordeborn Author • Edited on

Hi! Sorry, completely missed this question 🙈 if I understand you correctly you're after whether it's possible to paginate on the top level? If so then yes, totally possible! The top level Query type is just another type, so you can make a fragment on Query, just like you would on User.

Collapse
sebastienh profile image
Sébastien Hamel

Thanks! Really appreciated!!!