Skip to main content

Pagination

You're encouraged to read up on connections in GraphQL prior to reading this section. Here's an article, and there's a link to the official GraphQL documentation on pagination which explains the rationale for using connections well.

The best practice for doing pagination in GraphQL is leveraging a concept called connections. Connections are an integral part of building a GraphQL server following best practices. ResGraph comes with a number of conveniences to make working with connections simpler. Let's dive into what they are and how to use them.

Throughout this text, remember that while ResGraph ships with helpers for connections, connections are nothing but a specification. You can create your own hand rolled connections by just defining types, you don't have to use what ResGraph ships. It's just there for convenience.

Setting up a simple connection

Connections are regular GraphQL object types in ResGraph. Define your edge and connection explicitly:

@gql.type
type user = {
id: string,
@gql.field name: string
}

/** An edge to a user. */
@gql.type
type userEdge = {
/** A cursor for use in pagination. */
@gql.field
cursor: string,
/** The item at the end of the edge. */
@gql.field
node: option<user>,
}

/** A connection to users. */
@gql.type
type userConnection = {
/** Information to aid in pagination. */
@gql.field
pageInfo: ResGraph.Connections.pageInfo,
/** A list of edges. */
@gql.field
edges: option<array<option<userEdge>>>,
}

There, we have the simplest possible connection set up using regular object types. It generates this GraphQL:

"""
A connection to users.
"""
type UserConnection {
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!

"""
A list of edges.
"""
edges: [UserEdge]
}

"""
An edge to a user.
"""
type UserEdge {
"""
The item at the end of the edge.
"""
node: User

"""
A cursor for use in pagination.
"""
cursor: String!
}

You can then return this connection from a field just like you'd return any type.

Helpers for constructing connections

When doing real connection based pagination on the backend you'll naturally fill in the full connection yourself as you return it from fields.

But, it can be useful to present list data to the client as a connection even if what you have on the backend isn't a real connection. For this, ResGraph ships with a few helpers incorporated from graphql-relay.

Let's look at how we can leverage those helpers to return a connection even if all we have on the backend is a list of user and not a real connection:

@gql.type
type user = {
id: string,
@gql.field name: string,
}

/** An edge to a user. */
@gql.type
type userEdge = {
@gql.field cursor: string,
@gql.field node: option<user>,
}

/** A connection to users. */
@gql.type
type userConnection = {
@gql.field pageInfo: ResGraph.Connections.pageInfo,
@gql.field edges: option<array<option<userEdge>>>,
}

let fromSyntheticConnection = (
connection: ResGraph.Connections.connection<ResGraph.Connections.edge<user>>,
): userConnection => {
pageInfo: connection.pageInfo,
edges:
connection.edges->Option.map(edges =>
edges->Array.map(connectionEdge =>
connectionEdge->Option.map(connectionEdge => {
cursor: connectionEdge.cursor,
node: connectionEdge.node,
})
)
),
}

/** All currently active users. */
@gql.field
let currentlyActiveUsers = async (
_: query,
~ctx: ResGraphContext.context,
~first,
~after,
~before,
~last,
): userConnection => {
// Returns array<user>
let activeUsers = await ctx.dataLoaders.activeUsers.load()

let syntheticConnection =
activeUsers->ResGraph.Connections.connectionFromArray(
~args={first, after, before, last},
)

syntheticConnection->fromSyntheticConnection
}

And this will generate the following GraphQL:

"""
A connection to users.
"""
type UserConnection {
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!

"""
A list of edges.
"""
edges: [UserEdge]
}

"""
An edge to a user.
"""
type UserEdge {
"""
The item at the end of the edge.
"""
node: User

"""
A cursor for use in pagination.
"""
cursor: String!
}

type Query {
"""
All currently active users.
"""
currentlyActiveUsers(
first: Int
after: String
before: String
last: Int
): UserConnection!
}

Let's look a bit deeper into what we did, and how it works:

  1. First notice we're adding all connection arguments (first/last/before/after) to our field function. Also notice we're not annotating them with types. args of connectionFromArray knows what types they should be, so we let ReScript infer that for us.
  2. We then call a fictive data loader that returns an array of user. Notice it returns an array, and not a connection.
  3. connectionFromArray turns that array into ResGraph's generic connection data shape.
  4. Finally, fromSyntheticConnection maps that generic data into the userConnection GraphQL type we expose in our schema.

connectionFromArray

This helper will create a synthetic connection from an array of items. It does that by simply slicing the array according to the arguments you give it, and keep track of where in the array it's currently at by using the item indexes of the array as cursors.

It's a simple and fast way of exposing array-based data as a connection to the frontend, so that the frontend can easily choose how much data to fetch, and paginate that data, even if the backend practically does not support pagination for that array of items.

For this to work, the array you leverage needs to be static (as in it doesn't change frequently). This is important, because if the array changes, you'll end up getting the wrong items in the client as you paginate.

Use this helper with caution. It's typically a good idea to use when you want to expose a list of something as a connection, preferably where it's not intended to be paginated a lot. If you intend to also paginate that list a lot, make it a real connection on the backend instead.

Extending the connection

Edges and connections are regular object types, so you can add fields to your connection (and your edges) by defining field functions via @gql.field, just like with any other object type.

If you find yourself wanting to change the documentation for a field in the generated connection, you can easily do so with a field function as well, that just returns the underlying data directly:

/** The user for this edge. */
@gql.field
let node = (edge: userEdge) => {
edge.node
}

Generates:

"""
An edge to a user.
"""
type UserEdge {
"""
The user for this edge.
"""
node: User

"""
A cursor for use in pagination.
"""
cursor: String!
}

Adding more data to the connection

Sometimes you'll want to expose fields on the connection that require more data than the basic connection shape allows for. Since connections are regular object types, you can just add those fields directly to your connection record.

@gql.type
type userConnection = {
@gql.field totalCount: option<int>,
@gql.field pageInfo: ResGraph.Connections.pageInfo,
@gql.field edges: option<array<option<userEdge>>>,
}

Whenever constructing the connection, populate the extra field alongside pageInfo and edges:

/** All currently active users. */
@gql.field
let currentlyActiveUsers = async (
_: query,
~ctx: ResGraphContext.context,
~first,
~after,
~before,
~last,
): userConnection => {
// Returns {activeUsers: array<user>, totalCount: int}
let {activeUsers, totalCount} = await ctx.dataLoaders.activeUsers.load()

let syntheticConnection =
activeUsers->ResGraph.Connections.connectionFromArray(
~args={first, after, before, last},
)

{
totalCount: Some(totalCount),
pageInfo: syntheticConnection.pageInfo,
edges:
syntheticConnection.edges->Option.map(edges =>
edges->Array.map(connectionEdge =>
connectionEdge->Option.map(connectionEdge => {
cursor: connectionEdge.cursor,
node: connectionEdge.node,
})
)
),
}
}

There, we've now added another field to our connection that's backed by extra data from the backend. This results in the following GraphQL:

"""
A connection to users.
"""
type UserConnection {
"""
The total amount of data available on the server.
"""
totalCount: Int

"""
Information to aid in pagination.
"""
pageInfo: PageInfo!

"""
A list of edges.
"""
edges: [UserEdge]
}

"""
An edge to a user.
"""
type UserEdge {
"""
The item at the end of the edge.
"""
node: User

"""
A cursor for use in pagination.
"""
cursor: String!
}

type Query {
"""
All currently active users.
"""
currentlyActiveUsers(
first: Int
after: String
before: String
last: Int
): UserConnection!
}

Rolling your own connection entirely

The helpers presented above are intended to solve the basic cases. If you have more advanced cases, like adding fields that can't be derived from the basic data to the edges of a connection, you're encouraged to just roll your own connection definition and construct the connection data by hand, leveraging the connection arguments first/last/before/after provided to you, and so on.

It's as easy as defining your own @gql.type for the edge and connection. Here's an example:

@gql.type
/** An edge in a connection. */
type userEdge = {
/** A cursor for use in pagination. */
@gql.field
cursor: string,
/** The item at the end of the edge. */
@gql.field
node: option<user>
}

/** A connection to a list of items. */
@gql.type
type userConnection = {
/** Information to aid in pagination. */
@gql.field
pageInfo: ResGraph.Connections.pageInfo,
/** A list of edges. */
@gql.field
edges: option<array<option<userEdge>>>
}

You'll need to implement the logic for constructing and paginating the connection yourself, but you can leverage inference and the ResGraph.Connections.connectionArgs type to help you take the correct connection arguments for your field:

/** Friends of the user. */
@gql.field
let friends = async (
user: user,
~ctx: ResGraphContext.context,
~first,
~after,
~last,
~before,
) => {
// Fictive loader that returns a complete `userConnection`.
// (~userId: string, ~args: ResGraph.Connections.connectionArgs) => promise<userConnection>
let userFriends = await ctx.dataLoaders.user.friends.load(
~userId=user.id,
~args={
first,
after,
last,
before,
},
)

Some(userFriends)
}

Here our data loader takes ResGraph.Connections.connectionArgs, which again let us not have to worry about the types of the args first/after/before/last, thanks to inference.