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:
-
Global object identification and the
Node
interface. We also have another article about that you can read here. - 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:
- 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.
- You define a separate query that can fetch more items for the list.
- 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
- 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:
- Profile information for our user, like name and email
- 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 generatedFriendsListPaginationQuery
- 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!
Top comments (3)
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
ofUser
) to demonstrate the use ofusePaginationFragment
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:Does Relay offer anything to directly deal with these?
Thanks again! :)
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.
Thanks! Really appreciated!!!