author-pic

WILL WARD

Full Stack Apollo GraphQL Tutorial: Part 5 - Pagination


Published on August 05, 2019

Part 5 Prerequisites: Successful completion of part 4.


Now that we have our data sources hooked up, let's deal with handling large data sets. If we load a large data set in one go, the user may have to wait an unacceptable amount of time before being able to interact with the data. Most of us are familiar with the experience of having to wait for a page to display correctly while it loads. With pagination, we can load our data to the page we'll display it on in small chunks, say, 20 items at a time. In our application, one item is one record of an earthquake event.

First, go to your quakeAPI file and update one of our custom data parameters. In the quakeReducerfunction, change the time property to cursor.

server/datasources/quake.js

...
return {
    magnitude: quake.properties.mag,
    location: quake.properties.place,
    when: datestring,
    cursor: `${timestamp}`,
    id: quake.id
};

Recall, we defined timestamp when building our custom data response; const timestamp = quake.properties.time.

Next, update your schema.

server/schema.js

type Quake {
  id: ID!
  location: String
  magnitude: Float
  when: String
  cursor: String
}

From Apollo Docs (Paginated Queries), you can get the code we'll need to update our Query type's quakes query. Just change the bits about launches to quakes and you're good to go.

server/schema.js

  type Query {
    quakes( # replace the current quakes query with this one.
    """
    The number of results to show. Must be >= 1. Default = 20
    """
    pageSize: Int
    """
    If you add a cursor here, it will only return results _after_ this cursor
    """
    after: String
  ): QuakeConnection!
    quake(id: ID!): Quake
    users: [User]
    # Queries for the current user
    me: User
}

"""
Simple wrapper around our list of quakes that contains a cursor to the
last item in the list. Pass this cursor to the quakes query to fetch results
after these.
"""
type QuakeConnection { # add this below the Query type as an additional type.
  cursor: String!
  hasMore: Boolean!
  quakes: [Quake]!
}

After updating the schema, you'll need to update server/utils.js to include the pagination logic (below is the completely updated file).

server/utils.js

const db = require("./db")

module.exports = {
    paginateResults: ({
        after: cursor,
        pageSize = 20,
        results,
        // can pass in a function to calculate an item's cursor
        getCursor = () => null,
      }) => {
        if (pageSize < 1) return [];
      
        if (!cursor) return results.slice(0, pageSize);
        const cursorIndex = results.findIndex(item => {
          // if an item has a `cursor` on it, use that, otherwise try to generate one
          let itemCursor = item.cursor ? item.cursor : getCursor(item);
      
          // if there's still not a cursor, return false by default
          return itemCursor ? cursor === itemCursor : false;
        });
      
        return cursorIndex >= 0
          ? cursorIndex === results.length - 1 // don't let us overflow
            ? []
            : results.slice(
                cursorIndex + 1,
                Math.min(results.length, cursorIndex + 1 + pageSize),
              )
          : results.slice(0, pageSize);
      },
    createStore: () => {
        const users = db.map(user => {
            return user
        })
        return { users }
    }
}

What's going on?
The function paginateResults takes in four parameters, after, pageSize, results, and the function getCursor. after is a value you pass in that is equal to the cursor attached to a quake event. It's a marker that will allow you to load the next twenty quake events after the last one displayed in the previous twenty. It would put a placeholder on the twentieth element so that the next twenty will start with the twenty-first element in the overall quake list, which in some cases may be several hundred quake objects (events) in length. pageSize lets you choose how many quake events should be displayed at one time. results is the overall quake list returned from the USGS Earthquake Catalog. getCursor would be a function that would generate a cursor if none were available on an item. We won't be dealing with such cases in this tutorial.

after is the cursor value we pass in. If we don't pass in a value, (!cursor) is truthy, and only the first twenty items in the results quake list are displayed. If we do pass in a value for after, there is a cursor, so we get a value called cursorIndex by using the JavaScript function findIndex (See the documentation). Once we get the cursorIndex, we check where we are in the results list. If we're at the end of the list, we return no more data. If not, we return either the next twenty items, or the remaining items in the list, whichever is fewer in number. If we aren't able to find a cursor that matches the one we pass in, we just display the first twenty items.

Finally, we update our quakes resolver.

server/resolvers.js

...
quakes: async (_, { pageSize = 20, after }, { dataSources }) => {
    const allQuakes = await dataSources.quakeAPI.getAllQuakes();
    // we want these in reverse chronological order
    allQuakes.reverse();
    const quakes = paginateResults({
        after,
        pageSize,
        results: allQuakes
    });
    return {
        quakes,
        cursor: quakes.length ? quakes[quakes.length - 1].cursor : null,
        // if the cursor of the end of the paginated results is the same as the
        // last item in _all_ results, then there are no more results after this
        hasMore: quakes.length
        ? quakes[quakes.length - 1].cursor !==
            allQuakes[allQuakes.length - 1].cursor
        : false
    };
},

What's going on?
We get the entire list of quakes, pass it into the function paginateResults as results along with pageSize and after, which get passed into our resolver from the client side. After displaying a page of quakes (if there were any to display), we set the cursor equal to the cursor value of the last element displayed on that page (or in that batch if we're loading the next twenty below the first twenty on the same page). If there weren't any quakes to display, the cursor is set to null. We check the cursor value of the last item (quake) displayed and we check the cursor value of the last item in the overall list. If the two cursor values are different, there are more results to be displayed. If they're the same, then we've reached the end of our list and can't display any more quakes.

Now, in GraphQL Playground, you can run the following query:

GraphQL Playground

query {
  quakes {
   cursor
    hasMore
    quakes {
      magnitude
      location
      when
      cursor
    }
  }
}

The first cursor value displayed (only one time and at the top of your query results) should match the cursor value attached to the last item displayed. There should only be twenty quakes displayed. If you pass the cursor value into your query as the after value, you should see the next twenty quakes in the overall list.

GraphQL Playground

query {
  quakes (after: "1388540766560") {
   cursor
    hasMore
    quakes {
      magnitude
      location
      when
      cursor
    }
  }
}

Of course, if your request to the Earthquake Catalog API is different than the one we've been using in this tutorial, you'll pass in a different cursor value.

So, that's it for pagination for now. The complete updated files for this part are given below.

server/datasources/quake.js

const { RESTDataSource } = require('apollo-datasource-rest');

class QuakeAPI extends RESTDataSource {
    constructor() {
        super();
        this.baseURL = 'https://earthquake.usgs.gov/fdsnws/event/1/';
    }

    async getAllQuakes() {
        const query = "query?format=geojson&starttime=2014-01-01&endtime=2014-01-02"
        const response = await this.get(query);
        return Array.isArray(response.features)
            ? response.features.map(quake => this.quakeReducer(quake))
            : [];
    }

    quakeReducer(quake) {

        const date = new Date(quake.properties.time)
        const year = date.getFullYear();
        const month = monthName(date.getMonth())
        const day = date.getDate();
        const hour = date.getHours();
        const minute = date.getMinutes() < 10 ? "0" + date.getMinutes() : date.getMinutes();
        const seconds = date.getSeconds();
        const datestring = `${month} ${day}, ${year} at ${hour}:${minute} and ${seconds} seconds`;
        const timestamp = quake.properties.time

        function monthName(index) {
            const monthLegend = {
                0: "January",
                1: "February",
                2: "March",
                3: "April",
                4: "May",
                5: "June",
                6: "July",
                7: "August",
                8: "September",
                9: "October",
                10: "November",
                11: "December"
            }
            return monthLegend[index];
        };
        return {
            magnitude: quake.properties.mag,
            location: quake.properties.place,
            when: datestring,
            cursor: `${timestamp}`,
            id: quake.id
        };
    }


}

module.exports = QuakeAPI;

server/schema.js

const { gql } = require('apollo-server');

const typeDefs = gql`
  type Query {
    quakes( # replace the current launches query with this one.
    """
    The number of results to show. Must be >= 1. Default = 20
    """
    pageSize: Int
    """
    If you add a cursor here, it will only return results _after_ this cursor
    """
    after: String
  ): QuakeConnection!
    quake(id: ID!): Quake
    users: [User]
    # Queries for the current user
    me: User
}

"""
Simple wrapper around our list of quakes that contains a cursor to the
last item in the list. Pass this cursor to the quakes query to fetch results
after these.
"""
type QuakeConnection { # add this below the Query type as an additional type.
  cursor: String!
  hasMore: Boolean!
  quakes: [Quake]!
}

type Quake {
  id: ID!
  location: String
  magnitude: Float
  when: String
  cursor: String
}
type User {
  id: ID!
  username: String!
  email: String!
  password: String!
  records: [Quake]
}
type Mutation {
  # if false, saving record failed -- check errors
  saveRecord(recordId: ID!): RecordUpdateResponse!
  # if false, deleting record failed -- check errors
  deleteRecord(recordId: ID!): RecordUpdateResponse!
  login(email: String): String # login token
}
type RecordUpdateResponse {
  success: Boolean!
  message: String
  records: [Quake]
}
`;

module.exports = typeDefs;

server/utils.js

const db = require("./db")

module.exports = {
    paginateResults: ({
        after: cursor,
        pageSize = 20,
        results,
        // can pass in a function to calculate an item's cursor
        getCursor = () => null,
      }) => {
        if (pageSize < 1) return [];
      
        if (!cursor) return results.slice(0, pageSize);
        const cursorIndex = results.findIndex(item => {
          // if an item has a `cursor` on it, use that, otherwise try to generate one
          let itemCursor = item.cursor ? item.cursor : getCursor(item);
      
          // if there's still not a cursor, return false by default
          return itemCursor ? cursor === itemCursor : false;
        });
      
        return cursorIndex >= 0
          ? cursorIndex === results.length - 1 // don't let us overflow
            ? []
            : results.slice(
                cursorIndex + 1,
                Math.min(results.length, cursorIndex + 1 + pageSize),
              )
          : results.slice(0, pageSize);
      },
    createStore: () => {
        const users = db.map(user => {
            return user
        })
        return { users }
    }
}

server/resolvers.js

const { paginateResults } = require('./utils');

module.exports = {
    Query: {
        quakes: async (_, { pageSize = 20, after }, { dataSources }) => {
            const allQuakes = await dataSources.quakeAPI.getAllQuakes();
            // we want these in reverse chronological order
            allQuakes.reverse();
            const quakes = paginateResults({
              after,
              pageSize,
              results: allQuakes
            });
            return {
              quakes,
              cursor: quakes.length ? quakes[quakes.length - 1].cursor : null,
              // if the cursor of the end of the paginated results is the same as the
              // last item in _all_ results, then there are no more results after this
              hasMore: quakes.length
                ? quakes[quakes.length - 1].cursor !==
                  allQuakes[allQuakes.length - 1].cursor
                : false
            };
          },
        quake: (_, { id }, { dataSources }) =>
            dataSources.quakeAPI.getQuakeById({ quakeId: id }),
        users: (_, __, { dataSources }) => 
            dataSources.userAPI.getUsers()
    }
};

If you like it, share it!