Lett's Go Build: Pagination with Relay and React. An Intermediary Tutorial
Pedro Arantes
Posted on November 12, 2020
TL;DR
This is a tutorial of how to create cursor-based pagination using Relay without a server and the final code can be seen here.
Table of Contents
Introduction
This tutorial is an extension of another tutorial I start to write about "Making Cursor-Based Pagination with AWS AppSync and DynamoDB." As I started to write the latter tutorial, I realized that it was getting too big so that I decided to split it into two tutorials. The first is focused only on the backend configuration of AWS AppSync and DynamoDB. The second one - this tutorial - only on the frontend.
Additionally, this tutorial was a way I found to document my learning process. Before starting writing this tutorial, I was studying pagination with Relay and, in order to organize my thoughts and processes, I wrote this article while learning Relay's pagination.
In this article, I'm going to create an app that has a user with his posts. The user may have many posts and, in a real app, it isn't good practice fetch all posts in a single request. When we have this case, pagination may be a good technique to be adopted to fetch a small amount of the posts each time.
Objectives
- Show step by step how to create pagination with Relay without connecting with a previously existing backend.
- Relay pagination with hooks and React Concurrent Mode.
- Create an app in which is possible to provide a GraphQL server endpoint to test the server cursor-based pagination (in our case, the server is AWS AppSync and DynamoDB).
Prerequisites
This is an intermediary tutorial because you should have a basic understanding of:
- React concurrent mode.
- Relay fragments.
- TypeScript.
Building the App
First Commit
For the first app setup, I created a project with CRA, activated React Concurrent Mode, and installed Theme-UI. This first implementation can be seen here.
App First Run
Installing Relay
You might want to check the step by step of the official Relay's documentation.
First, let's install React Relay experimental:
yarn add react-relay@experimental
Second, Relay compiler for TypeScript and GraphQL:
yarn add -D babel-plugin-relay graphql relay-compiler relay-compiler-language-typescript relay-config
Finally, the types:
yarn add -D @types/react-relay
Configuring Relay
Create a .babelrc
configuration to allow Relay to works.
// .babelrc
{
"plugins": ["relay"]
}
Also, we create a relay.config.json
// relay.config.json
module.exports = {
language: 'typescript',
src: 'src/',
schema: 'schema.graphql',
exclude: ['**/node_modules/**', '**/__mocks__/**', '**/__generated__/**'],
};
Creating the First Query
Before start creating the React components, let's define our first graphql.schema
. In this stage, we're going to focus only on the User entity. Our User type has only id
and name
properties and implements the interface Node
. Later in this text, I'll explain more about the Node
and the role it takes to make pagination works.
## schema.graphql
interface Node {
id: ID!
}
type User implements Node {
id: ID!
name: String
}
type Query {
user(id: ID!): User
}
schema {
query: Query
}
User.tsx
Also, add declare module 'babel-plugin-relay/macro';
to your react-app-env.d.ts
file.
// src/User.tsx
import * as React from 'react';
import { graphql } from 'babel-plugin-relay/macro';
import { useLazyLoadQuery } from 'react-relay/hooks';
import { Heading } from 'theme-ui';
const User = () => {
const { user } = useLazyLoadQuery(
graphql`
query UserGetUserDataQuery($userId: ID!) {
user(id: $userId) {
id
name
}
}
`,
{ userId: 'user1' }
);
if (!user) {
throw new Error('Cannot load user ;/');
}
return (
<div>
<Heading as="h3">{user.name}</Heading>
</div>
);
};
export default User;
Now, save the command relay-compiler
as an NPM script, e.g, "relay": "relay-compiler"
and execute the command yarn run relay
. This command will generate files inside src/__generated__/
folder with the query's types. To type our query, we need to import the type and set it in our useLazyLoadQuery
method, as shown below:
// src/User.tsx
...
import { Heading } from 'theme-ui';
import { UserGetUserDataQuery } from './__generated__/UserGetUserDataQuery.graphql';
const User = () => {
const { user } = useLazyLoadQuery<UserGetUserDataQuery>(
...
Faking Data
To finish the first query implementation, we need to add the Relay provider to our app and in our Relay environment. The code shown below will receive the request from Relay and return empty data. Also, to understand the Relay's request, we added a console.log
to see what happens when we run the App.
// relay/fakeEnvironment.ts
import {
Environment,
Network,
RecordSource,
Store,
FetchFunction,
} from 'relay-runtime';
const fetchQuery: FetchFunction = async (operation, variables) => {
console.log({ operation, variables });
return Promise.resolve({ data: {} });
};
const environment = new Environment({
network: Network.create(fetchQuery),
store: new Store(new RecordSource()),
});
export default environment;
Finally, the Relay provider with React Suspense.
// src/App.tsx
import * as React from 'react';
import { RelayEnvironmentProvider } from 'react-relay/hooks';
import User from './User';
import RelayFakeEnvironment from './relay/fakeEnvironment';
const App = () => {
return (
<RelayEnvironmentProvider environment={RelayFakeEnvironment}>
<React.Suspense fallback="loading...">
<User />
</React.Suspense>
</RelayEnvironmentProvider>
);
};
export default App;
Let's run your app with yarn start
to see what will happen. If everything works as planned, we'll get the error Cannot load user ;/
throw by User
component. This is an expected error because the data that is being returned in your fetchQuery
doesn't have the user
property. Checking the browser console, we can see the data logged from our provider:
With this print in hands, we change our fetchQuery
to return fake data to the query UserGetUserDataQuery
:
// relay/fakeEnvironment.ts
...
const fetchQuery: FetchFunction = async (operation, variables) => {
console.log({ operation, variables });
let data = {};
const { name } = operation;
if (name === 'UserGetUserDataQuery') {
data = { user: { id: variables.userId, name: 'Pedro' } };
}
return Promise.resolve({ data });
};
...
Now, if we reload the app, we'll see the page with the username chosen, in my case "Pedro".
The final code can be seen here. We've also bootstrapped the interface with some styles that weren't covered in this section.
Pagination
Now that we have our user data, we want to display some of their posts accordingly to a specific sorting rule, for instance, the newest, oldest, or the most relevant.
The point here is that we don't want (neither must do) fetch all posts of a user otherwise the database would receive a lot of requisitions, thus comprising our app's performance. To solve this problem, we use pagination to fetch some posts and if the user wants more posts, they request more data to our backend.
Understanding Connections, Edges, and Nodes
I've read these astonishing articles to understand the concepts behind Relay's cursor-based pagination better and I do recommend you read them too:
- Gabriel Nordeborn. Pagination with minimal effort in Relay.
- Caleb Meredith. Explaining GraphQL Connections
- Michael Hahn. Evolving API Pagination at Slack
- GraphQL Cursor Connections Specification
Now I'm going to explain these concepts with my words 😄
Nodes
An item, type, entity of our graph model.
Connections
Thinking in graphs, our nodes may have some relations with other nodes. These relations could be: a node User
has the association with another User
by a Friendship
property; a node Author
, with some nodes Articles
, by a WrittenBy
. The set of relations with the same property (Friendship
, WrittenBy
) of a node is called connections.
Connections may have metadata associated with the set of the elements returned. For instance, a connection returns some elements and the metadata about these elements could be: id of the first and last element.
Edges
Edges are the elements returned by a connection. Edges are the joint of a node and some metadata explaining better the connection between both nodes ("both nodes" means the returned with the edge and the source, the node from which we request the connection).
Bringing Connections, Edges, and Nodes to our Schema
The question here is: how do we create the connection between User and Post? When I started studying pagination, this was the first question I've asked myself. Pretending to answer it, I followed some steps to create the connections that I'm going to show you. These steps were created based on the Relay spec.
1. Create a property in our source node that will represent the connection that must return the edges and some metadata.
In our case, we'll add the property posts: PostsConnection
in our User
type and define the type PostsConnection
. As we discussed here, the connection type must return edges
and some metadata. Specifically for cursor-based pagination, we need to provide metadata related to the requested pagination, some page info, whose type we'll call PageInfo
. This type must have these properties:
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
hasNextPage
and hasPreviousPage
are self-explanatory and it'll be clearer when we implement the example. startCursor
and endCursor
will be covered when we defined the edge type because the cursor is a metadata of the edge type.
## schema.graphql
...
type User implements Node {
id: ID!
name: String
posts(
first: Int,
after: String,
last: Int,
before: String
): PostsConnection
}
type PostsConnection {
edges: [PostEdge]
pageInfo: PageInfo!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
...
The arguments first
and after
are used to perform forward pagination and are described here. last
and before
, to perform backward pagination, and are defined here.
Another example of metadata that might be added along with pageInfo
is systemCost
, whose properties might be queryTime
and memoryUsed
. It'd represent the cost of the query to our system. I used this example to help we understand better what metadata is in a connection.
2. The edge must return the node and some metadata.
Here is our schema:
## schema.graphql
...
type PostEdge {
cursor: String!
node: Post!
}
type Post implements Node {
id: ID!
title: String!
description: String
}
...
The cursor
property is like an id for the edge. With the cursor
property we must be able to retrieve and locate that edge on your backend.
Besides cursor
, other metadata might be relationship
. If the Post
has an author and/or co-author, this metadata would be responsible to tell the relationship between the User
and the Post
. A rule of thumb for me is: if we need some data to complement the relation between two nodes that doesn't make sense be added to any node, probably it'll fit in the edge metadata.
## schema.graphql
...
enum AuthorPostRelationship {
Author
CoAuthor
}
type PostEdge {
cursor: String!
relationship: AuthorPostRelationship!
node: Post!
}
...
Creating UserPosts
Component
Let's create a user posts components to perform pagination using fake data. The first scratch is shown below. Note that this code doesn't work yet because we don't have yet:
- The
@refetchable
directive. - The
@argumentDefinitions
directive. - The
@connection
directive. - The
node
query. - A fake resolver.
// src/User.tsx
import * as React from 'react';
import { graphql } from 'babel-plugin-relay/macro';
import { useLazyLoadQuery, usePaginationFragment } from 'react-relay/hooks';
import { Box, Button, Flex, Heading, Styled } from 'theme-ui';
import { UserGetUserDataQuery } from './__generated__/UserGetUserDataQuery.graphql';
const UserPosts = ({ user }: any) => {
const {
data,
hasNext,
loadNext,
isLoadingNext,
hasPrevious,
loadPrevious,
isLoadingPrevious,
} = usePaginationFragment(
graphql`
fragment User_posts on User {
posts(first: $first, after: $after, last: $last, before: $before) {
edges {
node {
id
title
description
}
}
}
}
`,
user
);
...
};
const User = () => {
const { user } = useLazyLoadQuery<UserGetUserDataQuery>(
graphql`
query UserGetUserDataQuery($userId: ID!) {
user(id: $userId) {
id
name
...User_posts
}
}
`,
{ userId: 'user1' }
);
if (!user) {
throw new Error('Cannot load user ;/');
}
return (
<div>
<Heading as="h3" sx={{ fontSize: 5 }}>
User Name: {user.name}
</Heading>
<UserPosts user={user} />
</div>
);
};
export default User;
The @refetchable
Directive
The first directive to be added is the @refetchable
. Fragments can't be queried by themselves, we need a parent query in which the fragment will be added. When we add this directive, Relay's engine automatically generates a new query for us when we require new pagination data. You might want to read more about this on Relay docs.
// src/User.tsx
...
fragment User_posts on User
@refetchable(queryName: "UserPostsPaginationQuery") {
posts(first: $first, after: $after, last: $last, before: $before)
...
The parameter queryName
defines the name of the query that will be created.
The @argumentDefinitions
Directive.
This directive provides a way to add variables to our fragment. If we weren't able to this, we would have to provide them in our parent component where the parent query is located. For instance, as we want to provide first
, after
, last
, before
, if we didn't have the directive, we would have to provide them to our UserGetUserDataQuery
query inside our User
component. The User
component doesn't perform the pagination, it even doesn't know what the UserPosts
component is doing.
// src/User.tsx
...
fragment User_posts on User
@argumentDefinitions(
first: { type: "Int" }
after: { type: "String" }
last: { type: "Int" }
before: { type: "String" }
)
@refetchable(queryName: "UserPostsPaginationQuery") {
...
The @connection
Directive.
The @connection
directive indicates to Relay that a pagination operation will be performed over a specific connection, in our case, posts
.
// src/User.tsx
...
@refetchable(queryName: "UserPostsPaginationQuery") {
posts(first: $first, after: $after, last: $last, before: $before)
@connection(key: "User_posts_postsConnection") {
edges {
node {
id
title
description
}
}
}
}
...
key
is an identifier of this connection. It's used to help cache updates (not our case). You may want to read more about connections from official docs.
The node
Query
At this point, if we execute yarn run relay
, we'll get this error: Internal Error: Unknown field 'node' on type 'Query'.
Let's talk a little about it.
You may want to read Global Object Identification and The magic of the Node interface to understand more about node
query. In our case, it'll be used to create new queries when the user requests new pagination data. This concept will be clearer in the next topic because we'll see an example of the created query and fetching new data.
We need to add the node
query in your schema.graphql
:
## schema.graphql
type Query {
user(id: ID!): User
node(id: ID!): Node
}
A fake resolver
The final Relay's environment code can be seen here.
The first point is that we created a method called getPostsConnection
. This method receives cursor variables (after
, before
, first
, and last
) and returns posts connection to our user. We also defined a limit of posts creation to be able to notice when the properties hasNextPage
and hasPreviousPage
become falsy.
The second point is that we can inspect the query we receive when we trigger a pagination action. The example below is the query sent by Relay when we request more posts:
query UserPostsPaginationQuery(
$after: String
$before: String
$first: Int
$last: Int
$id: ID!
) {
node(id: $id) {
__typename
...User_posts_pbnwq
id
}
}
fragment User_posts_pbnwq on User {
posts(first: $first, after: $after, last: $last, before: $before) {
edges {
node {
id
title
description
__typename
}
cursor
}
pageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
}
}
id
}
and the variables:
{
"after": "3",
"before": null,
"first": 4,
"id": "user1",
"last": null
}
We may notice the name of the created query - UserPostsPaginationQuery
- it is the name we've defined in our @refetchable
directive.
Also, there is the node
query inside UserPostsPaginationQuery
. This is how Relay's works: it retrieves the id
of the parent node of the fragment, in our case, user1
, whose type is User
and pass it to node
. node
can assume any type of our schema that implements Node
as a result of Global Object Identification.
Finally, we create a response to the query above:
// src/relay/fakeEnvironment.ts
...
if (name === 'UserPostsPaginationQuery') {
data = {
node: {
id: variables.id,
name: 'Pedro',
posts: getPostsConnection(variables as any),
__typename: 'User',
},
};
}
...
At this point, loading next and previous posts should work and the pagination is disabled when posts ids reach about -15
or `15.
The final code can be seen here.
Conclusion
For me, writing this article helped me understand better how cursor-based pagination works because it is a concept that I studied while written this post. Also, it'll be a guide to use when I need to implement Relay pagination using hooks.
For you, I hope this article improved your acknowledgment of cursor-based pagination, Relay with hooks, and the concepts behind nodes, edges, and connections.
Finally, this project will be used as a client for the one I'm writing about cursor-based pagination with AWS AppSync and DynamoBD.
Acknowledgments
Thanks to @sseraphini for encouraging me to write more and review this tutorial. I do recommend you to send a DM to him, you'll be amazed with the conversation you'll have.
Cover photo by Roman Trifonov on Unsplash
Please, feel free to give me any feedback. This was my first tutorial and I'll appreciate any feedback to help me improve or just to know how you feel reading this tutorial :) You can also contact me on Twitter @arantespp
Posted on November 12, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 12, 2020