It's the year 2020. You use a modern front-end stack of Relay, GraphQL, React and TypeScript. You can build an infinite scroll 'feed' type UI totally out of the box with these tools, by mostly putting together boilerplate (proper connections, along with a pagination container). You have a design system, and are rapidly building up a component library. Things are great!
Then you take a look at the latest design comps for a 'browse' type page, and you see that the controversial infinite scroll has been replaced by a more traditional pagination bar.
You know the one. Like the following, from Amazon:
You start to realize that the cursor-based setup of a connection, along with a Relay pagination container, does not
lend itself to this more traditional UI. For one thing, a user can arbitrarily 'jump' to any page by including a
?page=X query param (typically). For another, the user can only actually see the current page of content, versus
a feed. As you go to sleep and dream of REST, Rails controllers, kaminari,
will_paginate, and a simpler time, you start to have a vision...
To get a good primer of what a GraphQL connection is and why they're so useful, read this excellent Apollo blogpost. Seriously. It's one of the best writeups on this subject out there. I'll assume basic familiarity with connection types from this point forward.
We prefer to use connections in place of lists almost always. Not only do they provide a preferred cursor-based
pagination API for clients, but their type specification (a map vs a list) is naturally forward-looking. Even if
you do no pagination, a pure list type can't accomodate returning other metadata (such as a
the list. Additionally, if your data is very relational and better represented as nodes connected by edges (which
would contain data about the 'join' of the two nodes), the connection type gives one more flexibility than a simple
list. This (and more) is all covered in the aforementioned blog post.
So, let's start by taking a look at our desired pagination UI, and think about what kind of schema/components make sense.
There looks to be several types of appearances we want to show, based on the total size of our list and fixed page size chosen, as well as the current page. There's also some edge cases of empty lists, or lists that are short enough to just display all their page numbers. Users can click on any displayed page number to jump to it. There's a prev/next navigation, which brings the user forward and back one page at a time. Whenever the current page changes, the URL should update accordingly. For a responsive implementation, we want to hide the page numbers, and only show the prev/next toggles on small screens.
Wow! Ok, we have our work cut out for us. But wait til you see how easy this is! There'll be links to our actual production components involved (all open-source) at the end.
Let's tackle the first part of this, which is: how do we adapt the GraphQL connection spec in order to hold necessary information that a UI might need? Generally we want the UI's to be as simple as possible, and so if the server could construct a suitable pagination schema, that would be preferable. The simpler our UI, and the more business logic and good abstractions made in our GraphQL server, the more portable and reusable this all becomes.
What kind of data does the UI need, in order to render a particular page of contents? Well, for a particular page we'd need to render the actual number it corresponds to. We'll need to know if this is the current page or not (so we can distinguish it in the UI from neighboring pages). And, we'll need to know the actual cursor (think: opaque string) that corresponds to this page number. It seems likely we'll need some sort of way to construct cursors from page numbers, on the server.
So, check this out:
1 2 3 4 5 6 7 8 9 10 11 12 13
This is our pagination schema. Including a field of type
pageCursors as a connection-level field, onto a
connection, is sufficient for a UI to incredibly simply 'just render' a correct pagination bar always, and be able
to hook up proper interactions. We can fully construct a simple UI (using Relay, shown in the next section) that
can present and allow for the interactions desired, for windowed pagination.
But, of course we're glossing over the implementation for such a
pageCursors type, so let's check that out before
looking at how a client might consume this.
Our backing API's largely still paginate via offsets, and not cursors. That is, they accept page/size or size/offset style arguments. We use graphql-relay-js, which includes helpers to make sure types and resolvers are compatible with some Relay expectations. So, we use this library to generate our cursors, and can convert the cursor to an offset. A page of 4 with a size of 10, returns the elements numbered 30 - 39 in that list. So a page of 4 (and size of 10), is equivalent to an offset of 29 (and size of 10). We have:
1 2 3
This gives us the offset of the last value of the previous page. While our upstream services are all still paginating using this size/offset method, the GraphQL cursor spec prefers opaque cursors to be used on the client. This allows the actual implementation of pagination to change upstream while clients remain unaffected. Thus if we ever update our upstream pagination arguments/logic/setup, we could update this schema implementation accordingly, and clients would continue to be functional.
For inspiration in constructing our
around groups, we turn to
Fingertips and their
That code goes through the various cases possible (a short list, a long list where the current page is near the
front, middle or end, various degenerate cases, etc.), and returns a proper structure that represents this data. It
can handle all combinations of list sizes, and current position relative to the total size.
In pseudo-code, it looks like:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
Our full implementation of that method can be found here.
For a real-life example, check out this link, corresponding to a page number of 4. You can adjust the arguments to see how the output changes based on where you are in the list. Try putting different cursor values in! It looks like:
Let's look at a couple of other pieces of data requested here. One of these is a
previous page cursor. This is to
support that action (the prev/next toggles) in the UI. However, we don't need a custom
next item to support that
behavior. That's because we tend to use
forward-style pagination arguments
with connections, which means the connection will already return the data needed for that action (remember, you can
implement a scrolling infinite scroll feed that always takes you to the next page right out of the box).
endCursor are those fields from the
which give you that information.
Companion UI Component
Ok, now that we have a connection and corresponding fields that provide the needed data, let's take a look at a simple React component that can render this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
That's basically it, visually speaking! The data provided by our GraphQL server is sufficient to render what's
needed. You can see such a UI component in our design system
It looks very similar to the above code. Of note, is since this is a simple UI component, it is vanilla React. It
is not a Relay component. It requires an
onNext to be passed as props.
Relay Integration Step I
Now, let's take a look at how we can build a Relay container that will use the above UI component. First, let's build a Relay-wrapped component of the above UI component. This is a fragment container, and lists all the fields needed:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
As a fragment container, this doesn't have the ability to fetch anything by itself. We want to pass in an
onNext prop from a parent, as well as the
endCursor data. Check out
in our library to see how we take that vanilla React component mentioned above, and use the above fragment to make
a Relay fragment container out of it.
Now, we need to decide what kind of parent container is appropriate, and how this fragment container will be used.
Relay Integration Step II
This is going to be confusing, but for this step, we use a refetch container in order to present our paginated collection view, rather than the aptly-named Relay pagination container. The latter is more suited for an infinite scroll feed view (presenting all content already fetched, only adjacent pages in a particular direction are able to be scrolled to, etc.) vs. the windowed pagination we are trying to accomplish. The refetch container is a much more natural fit for our use case, despite the naming.
That fragment looks like:
1 2 3 4 5 6 7 8 9 10 11 12
We include our
pageCursors fragment, as well as the
endCursor from the
pageInfo object. We
need to provide the
onNext callbacks as well. Since this component will have access to a
relay prop since it is a refetch container, those look
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
The refetch query defined for the container will look like:
1 2 3
We're pretty much done, this is all just Relay boilerplate at this point.
Putting it all together, our refetch container winds up rendering a fully functional pagination component in one line:
That's it! Any connection can have this pagination functionality added to it very simply. You include the page cursor schema on the server for that type (we have a factory method to help us do that automatically for any connection type). Then, following the above steps, you can quickly build a Relay refetch container that displays and seamlessly paginates any list.
You can see an example of this in numerous places on the Artsy website. Head on over to our Artworks browse experience and have fun filtering and searching/browsing through all accessible works! The pagination controls and functionality on this page, and others, are built using the technique described in this post.