Connections & Pagination
In this section, we’ll see how to handle collections of many items, including paginated lists and infinite scrolling. In Relay, paginated and infinite-scrolled lists are handled using an abstraction known as a Connection.
Relay does a lot of the work for you when handling paginated collections of items. But to do that, it relies on a specific convention for how those collections are modeled in your schema. This convention is powerful and flexible, and comes out of experience building many products with collections of items. Let’s step through the design process for this schema convention so that we can understand why it works this way.
There are three important points to understand:
- Edges themselves have properties — for example, in a list of your friends, the date when you friended that person is a property of the edge between you, rather than of the other person per se. We handle this by creating nodes that represent the edges.
- The list itself has properties, such as whether or not there is a next page available. We handle this with a node that represent the list itself as well as one for the current page.
- Pagination is done by cursors — opaque symbols that point to the next page of results — rather than offsets.
Imagine we want to show a list of the user’s friends. An a high level, we imagine a graph where the viewer and their friends are each nodes. From the viewer to each friend node is an edge, and the edge itself has properties.
Now let’s try to model this situation using GraphQL.
In GraphQL, only nodes can have properties, not edges. So the first thing we’ll do is represent the conceptual edge from you to your friend with its very own node.
Now the properties of the edge are represented by a new type of node called a “FriendsEdge
”.
The GraphQL to query this would like this:
// XXX example only, not final code
fragment FriendsFragment1 on Viewer {
friends {
since // a property of the edge
node {
name // a property of the friend itself
}
}
}
Now we have a good place in the GraphQL schema to put edge-specific information such as the date when the edge was created (that is, the date you friended that person).
Now consider what we would need to model in our schema in order to support pagination and infinite scrolling.
- The client must be able to specify how large of a page it wants.
- The client must be informed as to whether any more pages are available, so that it can enable or disable the ‘next page’ button (or, for infinite scrolling, can stop making further requests).
- The client must be able to ask for the next page after the one it already has.
How can we use the features of GraphQL to do these things? Specifying the page size is done with field arguments. In other words, instead of just friends
the query will say friends(first: 3)
, passing the page size an argument to the friends
field.
For the server to say whether there is a next page or not, we need to introduce a node in the graph that has information about the list of friends itself, just like we introduces a node for each edge to store information about the edge itself. This new node is called a Connection.
The Connection node represents the connection itself between you and your friends. Metadata about the connection is stored there — for example, it could have a totalCount
field that says how many friends you have. In addition, it always has two fields which represent the current page: a pageInfo
field with metadata about the current page, such as whether there is another page available — and an edges
field that points to the edges we saw before:
Finally, we need a way to request the next page of results. You’ll notice in the above diagram that the PageInfo
node has a field called lastCursor
. This is an opaque token provided by the server that represents the position in the list of the last edge that we were given (the friend “Charmaine”). We can then pass this cursor back to the server in order to retrieve the next page.
By passing the lastCursor
value back to the server as an argument to the friends
field, we can ask the server for friends that are after the ones we’ve already retrieved:
This overall scheme for modeling paginated lists is specified in detail in the GraphQL Cursor Connections Spec. It is flexible for many different applications, and although Relay relies on this convention to handle pagination automatically, designing your schema this way is a good idea whether or not you use Relay.
Now that we've stepped through the underlying model for Connections, let’s turn our attention to actually using it to implement Comments for our Newsfeed stories.
Implementing “Load More Comments”
Take a look once more at the Story
component. There’s a StoryCommentsSection
component that you can add to the bottom of Story
:
@react.component
let make = (~story) => {
...
<Card>
...
<StorySummary summary={story.summary} />
<StoryCommentsSection story={story.fragmentRefs} />
</Card>
}
And add StoryCommentsSection
’s fragment to Story
’s fragment:
module StoryFragment = %relay(`
fragment Story_story on Story {
...
thumbnail @required(action: NONE) {
...Image_image @arguments(width: 400)
}
...StoryCommentsSection_story
}
`)
At this point, you should see up to three comments on each story. Some stories have more than three comments, and these will show a "Load more" button, although it isn't hooked up yet:
Now go to StoryCommentsSection
and take a look:
module Fragment = %relay(`
fragment StoryCommentsSection_story on Story {
comments(first: 3) {
pageInfo {
startCursor
hasNextPage
}
edges {
node {
id
...Comment_comment
}
}
}
}
`)
@react.component
let make = (~story) => {
let data = Fragment.use(story)
<div>
{switch data.comments {
| Some({edges: Some(edges), pageInfo: Some({hasNextPage})}) =>
<>
{edges
->Array.filterMap(edge =>
switch edge {
| Some({node: Some(node)}) => Some(<Comment key=node.id comment=node.fragmentRefs />)
| _ => None
}
)
->React.array}
{switch hasNextPage {
| Some(true) => <LoadMoreCommentsButton onClick=ignore />
| Some(false) | None => React.null
}}
</>
| _ => React.null
}}
</div>
}
Here we see that StoryCommentsSection
is selecting the first three comments for each story using the Connection schema convention: the comments
field accepts the page size as an argument, and for each comment there is an edge
and within that a node
containing the actual comment data — we’re spreading in CommentFragment
here to retrieve the data needed to show an individual comment with the Comment
component. It also uses the pageInfo
field of the connection to decide whether to show a “Load More” button.
Our task then is to make the “Load More” button actually load an additional page of comments. Relay handles the gritty details for us, but we do have to supply a few steps to set it up.
Augmenting the Fragment
Before we modify our component, the fragment itself needs three extra pieces of information. First, we need the fragment to accept the page size and cursor as fragment arguments rather than being hard-coded:
module Fragment = %relay(`
fragment StoryCommentsSection_story on Story
@argumentDefinitions(
cursor: { type: "String" }
count: { type: "Int", defaultValue: 3 }
) {
comments(after: $cursor, first: $count) {
pageInfo {
startCursor
hasNextPage
}
edges {
node {
id
...Comment_comment
}
}
}
}
`)
Next, we need to make the fragment refetchable, so that Relay will be able to fetch it again with new values for the arguments — namely, a new cursor for the $cursor
argument:
module Fragment = %relay(`
fragment StoryCommentsSection_story on Story
@refetchable(queryName: "StoryCommentsSectionPaginationQuery")
@argumentDefinitions(
...
Now there’s just one more change we need to make to the fragment. Relay needs to know which field within the fragment represents the Connection that we’re going to paginate over. To do that, we mark it with a @connection
directive:
module Fragment = %relay(`
...
comments(after: $cursor, first: $count)
@connection(key: "StoryCommentsSection_story_comments") {
...
`)
The @connection
directive requires a key
argument which must be a unique string — here formed from the fragment name and field name. This key is used when editing the connection’s contents during mutations, as we’ll see in the next chapter.
Now that we've added the @connection
directive, we can use a getConnectionNodes
helper to make accessing the nodes of the connection much cleaner.
@react.component
let make = (~story) => {
let data = Fragment.use(story)
let comments = data.comments->Fragment.getConnectionNodes
let hasNextPage =
data.comments
->Option.flatMap(c => c.pageInfo->Option.flatMap(p => p.hasNextPage))
->Option.getWithDefault(false)
}
Instead of switching on edges and nodes, you can just do
{comments
->Array.map(comment => <Comment key=comment.id comment=comment.fragmentRefs />)
->React.array}
{switch hasNextPage {
| Some(true) => <LoadMoreCommentsButton onClick=ignore />
| Some(false) | None => React.null
}}
which looks much neater. You can also add some @required
s if you like to avoid mapping over pageInfo to get hasNextPage
The usePagination hook
Now that we’ve got the fragment all souped up, we can modify our component to implement the Load More button.
Change this line at the top of the StoryCommentsSection
component
let data = Fragment.use(story)
to
let {data, loadNext} = Fragment.usePagination(story)
let onLoadMore = () => loadNext(~count=3)->RescriptRelay.Disposable.ignore
and pass onLoadMore
to the button: <LoadMoreCommentsButton onClick=onLoadMore />
Now the Load More button should cause another three comments to be loaded.
Improving the User Experience with useTransition
As it stands, there’s no user feedback when you click the “Load More” button until the new comments have finished loading and then appear, so let’s show a spinner while the new data is loading — but without hiding the existing UI.
Additionally, every user action with results that aren’t immediate should be wrapped in a React transition. This allows React to prioritize different updates: for example, if when the data becomes available and React is rendering the new comments, the user clicks on another tab to navigate to a different page, React can interrupt rendering the comments in order to render the new page that the user wanted.
We wrap our call to loadNext
inside a React transition and use use the isLoadingNext
flag from the usePagination
hook to render a small spinner
@react.component
let make = (~story) => {
let (isPending, startTransition) = ReactExperimental.useTransition()
let {data, loadNext, isLoadingNext} = StoryCommentsSectionFragment.usePagination(story)
let hasNextPage =
data.comments
->Option.flatMap(c => c.pageInfo->Option.flatMap(p => p.hasNextPage))
->Option.getWithDefault(false)
let onLoadMore = () =>
startTransition(() => {
loadNext(~count=3)->RescriptRelay.Disposable.ignore
})
<div>
{comments
->Array.map(comment => <Comment key=comment.id comment=comment.fragmentRefs />)
->React.array}
{switch hasNextPage {
| false => React.null
| true => <LoadMoreCommentsButton disabled={isLoadingNext || isPending} onClick={onLoadMore} />
}}
{isLoadingNext || isPending ? <SmallSpinner /> : React.null}
</div>
}
Infinite Scrolling Newsfeed Stories
Let’s use what we’ve learned about pagination to create an infinite scrolling newsfeed. The Newsfeed will be pretty much the same as loading more comments, except that loadNext
will be triggered automatically when the user scrolls to the bottom of the page, instead of by pressing a button.
Step 1 — Select the Connection Field in the Query
Right now our app uses the topStories
root field to fetch a simple array of the top 3 stories. The schema also provides a newsfeedStories
field on Viewer
which is a Connection. Let’s modify the Newsfeed
component to use this new field. Take a look once more at Newsfeed.res
— the GraphQL query at the top should look like this:
module NewsfeedQuery = %relay(`
query NewsfeedQuery {
topStories {
id
...Story_story
}
}
`)
Although we put the topStory
and topStories
fields at the top level of Query
for simplicity, it’s conventional to put fields related to the person who’s looking at the page or app under a field called viewer
. We’ll switch to that convention now that we’re using the field as it would be in a real app.
Change the fragment to
module NewsfeedQuery = %relay(`
query NewsfeedQuery {
viewer {
newsfeedStories(first: 3) @required(action: NONE) {
edges @required(action: NONE) {
node @required(action: NONE) {
id
...Story_story
}
}
}
}
}
`)
We’ve replaced topStories
with viewer
’s newsfeedStories
, adding a first
argument so that we fetch the first 3 stories initially. Within that we’ve selected the edge
and then the node
, which is a Story
node so we can spread the same StoryFragment
from before. We also select id
so that we can use it as a React key
attribute.
Step 2 - Map over the edges of the connection to render the stories
@react.component
let make = () => {
let {viewer} = NewsfeedQuery.use(~variables=())
switch viewer {
| None => React.null
| Some({newsfeedStories}) =>
<div className="newsfeed">
{newsfeedStories.edges
->Array.keepSome
->Array.map(({node}) => <Story key=node.id story={node.fragmentRefs} />)
->React.array}
</div>
}
}
Step 3 - Lower the newsfeed into a fragment
Relay’s pagination features only work with fragments, not entire queries. Although we could query directly in this component, in real applications the query is generally issued at some high-level routing component, which would rarely be the same component that’s showing a paginated list.
We'll fetch the newsfeedStories
in a fragment on Viewer
that we'll render in a separate component. Create a new file NewsfeedContent.res
with the following fragment
module Fragment = %relay(`
fragment NewsfeedContent_viewer on Viewer {
newsfeedStories(first: 3)
@connection(key: "NewsfeedContentsFragment_newsfeedStories") {
edges {
node {
id
...Story_story
}
}
}
}
`)
As in the previous example, use the fragment hook and the getConnectionNodes
helper to render the nodes in the connection:
@react.component
let make = (~viewer as viewerRef) => {
let contents = Fragment.use(viewerRef)
let stories = Fragment.getConnectionNodes(contents.newsfeedStories)
<>
{stories
->Array.map(story => <Story key={story.id} story={story.fragmentRefs} />)
->React.array}
</>
}
Now change Newsfeed.res
to spread the fragment in the query and render the new component
module NewsfeedQuery = %relay(`
query NewsfeedQuery {
viewer {
...NewsfeedContentFragment
}
}
`)
@react.component
let make = () => {
let {viewer} = NewsfeedQuery.use(~variables=())
<div className="newsfeed">
{switch viewer {
| None => React.null
| Some(viewer) => <NewsfeedContent viewer=viewer.fragmentRefs />
}}
</div>
}
Step 4 — Augment the Fragment for Pagination
Now that we’re using a Connection field for the stories and have ourselves a fragment, we can make the changes to the fragment that we need in order to support pagination. These are the same as in the last example. We need to:
- Add fragment arguments for the page size and cursor (
first
andafter
). - Pass those arguments in to the
newsfeedStories
field as field arguments. - Mark the fragment as
@refetchable
.
You should end up with something like this:
module Fragment = %relay(`
fragment NewsfeedContent_viewer on Viewer
@argumentDefinitions(
cursor: { type: "String" }
count: { type: "Int", defaultValue: 3 }
)
@refetchable(queryName: "NewsfeedContentRefetchQuery") {
newsfeedStories(after: $cursor, first: $count)
@connection(key: "NewsfeedContentsFragment_newsfeedStories") {
edges {
node {
id
...Story_story
}
}
}
}
`)
Step 5 — Call usePagination
Now we need to modify the NewsfeedContents
component to call usePagination
:
@react.component
let make = (~viewer as viewerRef) => {
let {data} = NewsfeedContentFragment.usePagination(viewerRef)
let stories = NewsfeedContentFragment.getConnectionNodes(data.newsfeedStories)
<>
{stories
->Array.map(story => <Story key={story.id} story={story.fragmentRefs} />)
->React.array}
</>
}
Step 6 — Paginate with a Scroll Trigger
We’ve prepared a component called InfiniteScrollTrigger
that detects when the bottom of the page is reached — we can use this to call loadNext
at the appropriate time. It needs to know whether more pages exist and whether we’re currently loading the next page — we can retrieve these from the return value of usePagination
:
@react.component
let make = (~viewer as viewerRef) => {
let {data, loadNext, hasNext, isLoadingNext} = Fragment.usePagination(viewerRef)
let stories = Fragment.getConnectionNodes(data.newsfeedStories)
let onEndReached = () => loadNext(~count=3)->RescriptRelay.Disposable.ignore
<>
{stories
->Array.map(story => <Story key={story.id} story={story.fragmentRefs} />)
->React.array}
<InfiniteScrollTrigger onEndReached hasNext isLoadingNext />
</>
}
We should now be able to scroll to the bottom of the page and see more stories loading. Feels like a real newsfeed app!
Summary
- Connections are a schema convention that Relay relies on to model the behavior of paginatable lists.
- It's generally a good idea to use Connections in your schema rather than simple lists. This gives you the flexibility to paginate if you need to.
Next, we'll finally look at how to update data on the server. Connections will play a role in that as well, as we'll see how to append a newly-created node to an existing Connection.