DEV Community

Cover image for Lett's Go Build: Pagination with Relay and React. An Intermediary Tutorial
Pedro Arantes for ttoss

Posted on

Lett's Go Build: Pagination with Relay and React. An Intermediary Tutorial

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

  1. Introduction
    1. Objectives
    2. Prerequisites
  2. Building the App
    1. First Commit
    2. App First Run
    3. Pagination
  3. Conclusion
  4. Acknowledgments

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
Enter fullscreen mode Exit fullscreen mode

Second, Relay compiler for TypeScript and GraphQL:

yarn add -D babel-plugin-relay graphql relay-compiler relay-compiler-language-typescript relay-config
Enter fullscreen mode Exit fullscreen mode

Finally, the types:

yarn add -D @types/react-relay
Enter fullscreen mode Exit fullscreen mode

Configuring Relay

Create a .babelrc configuration to allow Relay to works.

// .babelrc
{
  "plugins": ["relay"]
}
Enter fullscreen mode Exit fullscreen mode

Also, we create a relay.config.json

// relay.config.json
module.exports = {
  language: 'typescript',
  src: 'src/',
  schema: 'schema.graphql',
  exclude: ['**/node_modules/**', '**/__mocks__/**', '**/__generated__/**'],
};
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>(
...
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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:

Alt Text

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 });
};
...
Enter fullscreen mode Exit fullscreen mode

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:

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
}
...
Enter fullscreen mode Exit fullscreen mode

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
}
...
Enter fullscreen mode Exit fullscreen mode

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!
}
...
Enter fullscreen mode Exit fullscreen mode

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:

  1. The @refetchable directive.
  2. The @argumentDefinitions directive.
  3. The @connection directive.
  4. The node query.
  5. 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;
Enter fullscreen mode Exit fullscreen mode

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)
...
Enter fullscreen mode Exit fullscreen mode

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") {
...
Enter fullscreen mode Exit fullscreen mode

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
            }
          }
        }
      }
...
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

and the variables:

{
  "after": "3",
  "before": null,
  "first": 4,
  "id": "user1",
  "last": null
}
Enter fullscreen mode Exit fullscreen mode

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',
      },
    };
  }
...
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)