How to do effortless pagination with ReactJS, Relay Connections and graphql-ruby

By Jose Raymundo Cruz

Jose Raymundo Cruz
AlphaSights Engineering
9 min readFeb 22, 2016

--

Reach out via twitter

I work at AlphaSights and my team is in charge of building our company’s recruitment platform. This platform consists of a client side web application (React/Flux) and a backend RESTful API (rails).

In an effort to give both the client and server apps more flexibility, and to reduce the network load (among several other reasons) we decided to migrate from Flux/REST to Relay/GraphQL.

One of the first features we decided to build using Relay/GraphQL was a dashboard that consolidates a lot of important recruiting information into a single screen. A requirement for one of the components on this screen was to display an infinite list of candidates that automatically grow as the user scrolls to the bottom of the list, this means we needed to paginate the data. Let’s take a look at how we can do this using Relay.

GraphQL server

The wonderful gem graphql-ruby provides a great implementation of GraphQL on top of ruby, that’s what we use as our GraphQL server.

Relay connections

Relay proposes a standard to define a has-many relationship for a GraphQL field. This standard defines a common structure that allows Relay to paginate and filter the results in an efficient way by using cursors, which I’ll explain in a bit.

This is the definition of a Relay connection (from the Relay connection specs):

Relay’s support for pagination relies on the GraphQL server exposing connections in a standardized way. In the query, the connection model provides a standard mechanism for slicing and paginating the result set. In the response, the connection model provides a standard way of providing cursors, and a way of telling the client when more results are available.

Let’s look at an example using with this simple graph:

This would be the implementation (using graphql-ruby) and query example without using a Relay connection for the relationship:

Node definition

For the query:

// Query:query { viewer { newCandidates { recordId, name } } }// Result
{
"data":{
"viewer":{
"newCandidates":[
{
"recordId":1,
"name":"John Nash"
},
{
"recordId":2,
"name":"Issac Newton"
},
{
"recordId":6,
"name":"Charles Xavier"
}
]
}
}
}

As you can see, this works a first step to render the list of candidates, but this query will bring all the records from the database and that’s not what we want. Let’s take a look at what is the Relay connection standard and how we can convert this relationship into that model.

Relay connection defines a relationship an an object that has two root fields: edges and pageInfo. Edges is just a wrapper around the real results that adds extra data used for slicing the results (cursors), PageInfo has metadata related to the current page. This is how it looks like:

connectionName {
edges {
node, // the actual node
cursor
},
pageInfo {
hasNextPage,
hasPreviousPage
}
}

This shows a few of the fields a connection defines, but for more information refer to the Relay connection spec. To better understand this let’s convert our previous relationship into a Relay connection (this is where the graphql-relay-ruby gem shines). As you will see all we need to do is just replace field with connection and change the field type to CommentType.connection_type, which is a helper method to create the Relay connection wrapper described above:

Now the query changes, as well as the results:

// The queryquery {
viewer {
newCandidates(first: 3) {
edges {
node {
recordId,
name
},
cursor
},
pageInfo {
hasNextPage,
hasPreviousPage
}
}
}
}
}
// The result
{
"data":{
"viewer":{
"newCandidates":{
"edges":[
{
"node":{
"recordId":1,
"name":"John Nash"
},
"cursor":"aWQtLS0x"
},
{
"node":{
"recordId":2,
"name":"Issac Newton"
},
"cursor":"aWQtLS0y"
},
{
"node":{
"recordId":3,
"name":"Darth Vader"
},
"cursor":"aWQtLS0z"
}
],
"pageInfo":{
"hasNextPage":true,
"hasPreviousPage":false
}
}
}
}
}

First thing we notice is that now the newCandidates field takes arguments, the first param tells GraphQL how many results we want. Second thing we notice, there is a cursor field for each edge, this cursor is and opaque string that the server can use to pinpoint the current page. We can use it on the client to ask the server for the next page like this:

// The Query
query { viewer { newCandidates(first: 3, after:\"aWQtLS0z\") { edges { node { recordId, name }, cursor}, pageInfo {hasNextPage, hasPreviousPage } } } }
// The result
{
"data":{
"viewer":{
"newCandidates":{
"edges":[
{
"node":{
"recordId":4,
"name":"Anakin Skywalker"
},
"cursor":"aWQtLS00"
},
{
"node":{
"recordId":5,
"name":"Michael Jackson"
},
"cursor":"aWQtLS01"
},
{
"node":{
"recordId":6,
"name":"Charles Xavier"
},
"cursor":"aWQtLS02"
}
],
"pageInfo":{
"hasNextPage":true,
"hasPreviousPage":false
}
}
}
}
}

As you can see using the cursor=“aWQtLS0z” we got from the last element of the first query we can ask the server for the 3 elements after that cursor (basically asking for the next page). The pageInfo object will let us known when there is no more pages so that we can stop (or modify the UI to hide the Next Page button). But what is this opaque value we receive?

Well, the cursor is pretty much whatever data the server needs to figure out the next page, and it is platform specific, so the GraphQL server is in charge of computing the value of the cursor and slicing results based on this value. Usually, the value is base64 encoded to make it opaque to the client (because the details of the cursor content is not important to the client, the client only knows it can ask the server for more data after/before a given cursor). In our case, the graphql-relay-ruby gem is handling the cursor for us, they provide a default implementation that connects with ActiveRecord models.

The best way to fully understand what the gem does under the hood is to look at the source code of the ArrayConnection, but let’s see some examples to showcase a few cases. Let’s look at the value we used above, the cursor=“aWQtLS0z”, if we base64-decode this value we get this:

Base64.decode64(“aWQtLS0z”)
=> “id---3”

The value is composed this way: [Column we are ordering the results with] [Separator (---)] [Value of the column]. The separator is a triple dash to avoid conflicts with any values that includes a dash (like a UUID). In this particular case we are ordering by id and this cursor points to the id=3. Let see what happens when we ask for a page using a different order, the creation_date of the candidate. We can do that by passing an order argument to the Relay connection:

// The query, ordering by created_at
query { viewer { newCandidates(first: 3, order: \”created_at\”) { edges { node { recordId, name }, cursor}, pageInfo {hasNextPage, hasPreviousPage } } } }
// The result
{
"data":{
"viewer":{
"newCandidates":{
"edges":[
{
"node":{
"recordId":10,
"name":"Evelyn Okuneva"
},
"cursor":"Y3JlYXRlZF9hdC0tLTIwMTYtMDItMDEgMTA6NTY6MTAgVVRD"
},
{
"node":{
"recordId":1066,
"name":"Ike Sauer"
},
"cursor":"Y3JlYXRlZF9hdC0tLTIwMTYtMDItMDIgMDA6MDI6MjQgVVRD"
},
{
"node":{
"recordId":110,
"name":"Enoch O'Reilly"
},
"cursor":"Y3JlYXRlZF9hdC0tLTIwMTYtMDItMDIgMDI6NDA6MjcgVVRD"
}
],
"pageInfo":{
"hasNextPage":true,
"hasPreviousPage":false
}
}
}
}
}

Wow what happened? First of all, the result is in a different order, since we are ordering by created_at date instead of by id. Second, the cursors are bigger, that’s because the information encoded in them grew, let’s take a look at the last record’s cursor:

Base64.decode64(“Y3JlYXRlZF9hdC0tLTIwMTYtMDItMDIgMDI6NDA6MjcgVVRD”)
=> “created_at---2016–02–02 02:40:27 UTC”

Now the cursor has the field created_at and the value of this field for each record, that way when we ask for the next page the server can easily get the next 3 candidates where the created_at > 2016–02–02 02:40:27 UTC. Of course, it is worth mentioning that this is a pretty basic implementation of cursors, and we get it for free with the graphql-ruby-relay gem, but if we have more complex pages (for example using some other filters) then we need to extend the default implementation and handle the cursors ourselves, luckily for us the gem provides an way to extend the connection if you don’t want to use the default implementation.

Let’s add some filtering to see how easily this is done with the gem. Here we add a new argument to the newCandidates field that will filter the candidates based on the current stage:

Which allow us to run this query:

// The query
query { viewer { newCandidates(first: 3, stageId: 8, order: \"created_at\") { edges { node { recordId, name, currentStage{ name } }, cursor}, pageInfo {hasNextPage, hasPreviousPage } } } }
// The result
{
"data":{
"viewer":{
"newCandidates":{
"edges":[
{
"node":{
"recordId":10,
"name":"Evelyn Okuneva",
"currentStage":{
"name":"Review"
}

},
"cursor":"Y3JlYXRlZF9hdC0tLTIwMTYtMDItMDEgMTA6NTY6MTAgVVRD"
},
{
"node":{
"recordId":110,
"name":"Enoch O'Reilly",
"currentStage":{
"name":"Review"
}

},
"cursor":"Y3JlYXRlZF9hdC0tLTIwMTYtMDItMDIgMDI6NDA6MjcgVVRD"
},
{
"node":{
"recordId":48,
"name":"Wilbert Nolan",
"currentStage":{
"name":"Review"
}

},
"cursor":"Y3JlYXRlZF9hdC0tLTIwMTYtMDItMDMgMDU6MTA6MTYgVVRD"
}
],
"pageInfo":{
"hasNextPage":true,
"hasPreviousPage":false
}
}
}
}
}

As you can see the results based on the selected stageId, and asking for the next page will continue to work as long as we send the same stageId.

“But wait a minute, we’ve been talking this whole time about Relay Connections, but I haven’t seen any Relay yet”… That’s right, let’s talk some Relay

Relay containers

Now that we have our server set up to return the Relay connection standard, we can hook up a Relay container with this query and fetch the paginated results of the infinite list. To create the infinite list we are going to use the react-list npm package. Here is the full example, and below I’ll explain how Relay handles this:

This is a straightforward toy example of how to setup the infinite list using react-list helper. The ReactList component takes an itemRenderer and a length, and it will call the itemRenderer with the index as we scroll down until the length is reached. In this example I just check if the last element of the list was rendered (which means the user scrolled to the bottom of the list), and if so then I trigger the next page fetch, but in a more realistic implementation you’d want to check that the user is 75–80% from the bottom so that by the time the user scrolls down the request has already finished loading the next page.

The next page fetch is triggered by modifying the pageSize variable of the Relay container (using this.props.relay.setVariables). If you look at it, this pretty much increments the pageSize argument by the size constant, on first load the pageSize=30, after the end of the list is reached pageSize=60, and so on.

Now, the second time we fetch data you may be thinking that Relay will trigger the fetch and retrieve 60 records, but Relay is smart enough to detect that it already fetched the first 30 on load, so it modifies the query to use the cursor of the record #30 on it’s cache and only fetches the new 30 records after that one. Let’s take a look at the query that is sent to the server onLoad and when we loadNextPage.

When page first loads

As expected, the query asks for the first 30 candidates using “newCandidates(first:30)”.

When we load more candidates

When we get to the end of the infinite list, we increase the size of the page to 60. The first thought is that the query that Relay will execute is something like “viewer {newCandidates(first: 60….)”, but Relay is smart and knows it already fetched the first 30, so it takes a look at the cursor of the last element of the previous result and executes the following query:

So the payload only returns the data that we need, the query is “newCandidates(after: “CURSOR”, first:30)”.

Conclusion

Relay is a robust solution to connect your React components with a GraphQL backend. It has a very well defined patterns that simplify data fetching, pagination and filtering. Using Relay connections creates a common architecture that give the client a simple API to slice results into different pages.

--

--

Jose Raymundo Cruz
AlphaSights Engineering

I consider myself a Software artist since software development is an art form