'How to implement infinite scroll with GraphQL and React' post illustration

How to implement infinite scroll with GraphQL and React

avatar

Apollo comes with a simple method fetchMore() that you can use to implement offset pagination with infinite scroll in your React application.

Offset pagination is very popular in modern applications due to its convenience: The data is presented as an endless stream, and you can infinitely scroll without reaching the end (in theory). In this tutorial, we explain how such pagination can be implemented in a React application with Apollo and apollo-link-state.

So how such pagination is done? The next short section summarizes what you need to do.

How to implement offset-based pagination with Apollo and React

First, we'd like to remind what the offset-based pagination is. With this kind of pagination, your client application must provide an offset and limit values when querying the server for new items to indicate what entries must be returned and also their quantity. And to implement offset pagination, we use these technologies:

  • Apollo Client to use GraphQL in React
  • react-apollo to use custom React components Query and ApolloProvider
  • apollo-link-state, a data provider and state manager from Apollo

Using the listed technologies, we can create necessary GraphQL resolvers, React components, and an instance of Apollo Client instance in order to implement pagination:

  • An instance of Apollo Client with configured apollo-link-state
  • GraphQL resolvers to be passed to apollo-link-state to retrieve the data
  • A React component that renders the data and asks for new entries when a condition is met
  • A Higher Order Component, also known as a provider, which sends GraphQL queries for new data

Our React application will retrieve chapters from the famous Harry Potter and Hobbit series of books and render them. This is how the end result looks:

Simple React application with offset-based pagination with infinite scroll created with Apollo and Apollo Link State

If you want to look at the code with our pagination example, check out these two links:

Let's now focus on the implementation.

Configuring apollo-link-state

The Apollo stack comes with apollo-link-state, a great package that helps to manage the state for our React apps and is used instead of Redux in applications that use GraphQL. Since we don't have a backend API for our demo app, we use apollo-link-state.

Here's App.js with configured Apollo Client and a basic component:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import React, { Component } from 'react';
import ApolloClient from 'apollo-boost';
import { ApolloProvider } from 'react-apollo';

import ChapterListQuery from '../chapters/ChapterListQuery';
import chapterResolvers from '../chapters/resolvers';

import './App.css';

const client = new ApolloClient({
  clientState: chapterResolvers
});

class App extends Component {
  render() {
    return (
      <ApolloProvider client={client}>
        <ChapterListQuery />
      </ApolloProvider>
    );
  }
}

export default App;

As you can see, apollo-link-state configurations are set in the clientState property, which points to the resolvers that retrieve items from the backend API or, in this app, from apollo-link-state.

Notice that ApolloProvider renders the ChapterListQuery component, which will be sending GraphQL queries. ChapterListQuery also returns a dumb component ChapterList to render chapters.

We're now going to create resolvers, and then we'll switch to the React components.

Creating GraphQL resolvers for apollo-link-state for offset pagination

A minimal configuration object for apollo-link-state should have two properties: defaults to return some data by default and resolvers to return new data.

Have a look at the code below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import mockChapters from '../data/chapters';

const getChapters = ({ limit = 10, offset = 0 }) =>
  mockChapters
    .slice(offset, offset + limit)
    .map(chapter => ({ ...chapter, __typename: 'Chapter' }));

export default {
  defaults: {
    chapters: getChapters({})
  },
  resolvers: {
    Query: {
      chapters: (_, variables) => getChapters(variables)
    }
  }
};

First, we import mockChapters, an array of chapter objects. Second, we implement the getChapters() function, which uses the limit and offset values to slice a chunk of the array and return only the required part.

Pay attention that apollo-link-state requires that each object has the __typename property with some value (such as Chapter) for normalization, which is why each object from mockChapters is transformed to a new object with this property.

To make sure that our React application renders the first ten chapters once you open it in the browser, we set the defaults.chapters property to a getChapters({}) call.

Finally, we set the query chapters to a getChapters() call with variables. As required by the offset-based pagination implementation, you can pass both limit and offset variables into getChapters(), although in this implementation we only pass an offset and always use a default limit.

Now we can focus on creating a component that sends a new GraphQL query each time you scroll to the end of the rendered list.

Creating a React component with a GraphQL query

Since it's a bad idea to create components that both render data and send GraphQL queries to the backend, we created ChapterListQuery, a Higher Order Component that's only concerned with querying the backend. Then we have another React component, ChapterList, used only for rendering. We'll look at ChapterList in the next section.

Here's ChapterListQuery:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import React from 'react';
import { Query } from 'react-apollo';
import gql from 'graphql-tag';

import ChapterList from './ChapterList';

const GET_CHAPTERS = gql`
  query chapters($offset: String) {
    chapters(limit: 10, offset: $offset) @client {
      id
      title
    }
  }
`;

const ChapterListQuery = () => (
  <Query query={GET_CHAPTERS}>
    {({ data, fetchMore }) =>
      data && (
        <ChapterList
          chapters={data.chapters || []}
          onLoadMore={() =>
            fetchMore({
              variables: {
                offset: data.chapters.length
              },
              updateQuery: (prev, { fetchMoreResult }) => {
                if (!fetchMoreResult) return prev;
                return Object.assign({}, prev, {
                  chapters: [...prev.chapters, ...fetchMoreResult.chapters]
                });
              }
            })
          }
        />
      )
    }
  </Query>
);

export default ChapterListQuery;

There are several key aspects that you need to pay attention to in the code snippet above:

GET_CHAPTERS query with offset and limit

Here's the GraphQL query:

1
2
3
4
5
6
7
8
const GET_CHAPTERS = gql`
  query chapters($offset: String) {
    chapters(limit: 10, offset: $offset) @client {
      id
      title
    }
  }
`;

GET_CHAPTERS is a typical GraphQL query that uses the gql method from Apollo. Notice that we specified these three values:

  • limit, the number of items that the resolver must send in one go.
  • offset, a pointer to the item starting from which the resolver will retrieve data. Offset equals the length of the previous data.
  • @client, a directive needed only for apollo-link-state. It tells Apollo that the data must be resolved from the cache and that making a network request isn't necessary.

This GraphQL query will send the limit and offset values to the resolver so that the resolver understands how much data it should return.

The Query wrapper component with the onLoadMore() method

We use a Query component (kindly presented by react-apollo) to access data from our component and, importantly, to get the fetchMore() method to fetch new data when necessary.

ChapterListQuery returns a new component, ChapterList, and passes it two props attributes: chapters and onLoadMore():

1
2
3
4
5
6
7
8
9
10
11
const ChapterListQuery = () => (
  <Query query={GET_CHAPTERS}>
    {({ data, fetchMore }) =>
      data && (
        <ChapterList
          chapters={data.chapters || []}
          onLoadMore={() => fetchMore(//...)} />
      )
    }
  </Query>
);

What's important to successfully implement offset-based pagination is to add the onLoadMore() function, which is shown below:

1
2
3
4
5
6
7
8
9
10
11
12
13
onLoadMore={() =>
  fetchMore({
    variables: {
      offset: data.chapters.length
    },
    updateQuery: (prev, { fetchMoreResult }) => {
      if (!fetchMoreResult) return prev;
      return Object.assign({}, prev, {
        chapters: [...prev.chapters, ...fetchMoreResult.chapters]
      });
    }
  })
}

As you can see, onLoadMore() calls the Apollo fetchMore() method, which gets returned by a GraphQL query and is available thanks to apollo-link-state.

fetchMore() receives an object parameter with two properties. First, we must pass the variables that the resolver will use to slice the next part of the array. We pass only an offset value, which always equals the length of the current array. You can also pass limit if it's necessary for your React app.

The other property inside fetchMore() is updateQuery(), a function that accepts two parameters — prev and a {fetchMoreResult} object. prev contains the data returned by the previous query, and {fetchMoreResult} stores new data.

Notice how updateQuery() changes the result of the current query on the fly via Object.assign(). Why is it necessary? Consider this: We need each GraphQL query to return only a part of items array for offset-based pagination. Our React app, however, displays not only the items just returned, but all the items including the ones loaded before. Hence, we must merge the current array loaded to our React application with its next part in updateQuery().

You can look at it as you first cut the original array mockChapters in parts and then re-create it chunk by chunk in the React app.

That's it for ChapterListQuery, and we can review the ChapterList component.

Creating the ChapterList component with infinite scroll

We need a component to display the list of chapters with a scroll bar. This component, ChapterList, receives items and the onLoadMore() function to load more data. And whenever we scroll to the bottom of the list, onLoadMore() must be called.

Here's the ChapterList implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import React from 'react';

const handleScroll = ({ currentTarget }, onLoadMore) => {
  if (
    currentTarget.scrollTop + currentTarget.clientHeight >=
    currentTarget.scrollHeight
  ) {
    onLoadMore();
  }
};

const ChapterList = ({ chapters, onLoadMore }) => (
  <div>
    <h2>Chapters</h2>
    <ul className="list-group chapters-list"
        onScroll={e => handleScroll(e, onLoadMore)}>
      {chapters.map(({ id, title }) => (
        <li key={id} className="list-group-item">
          {title}
        </li>
      ))}
    </ul>
  </div>
);

export default ChapterList;

First, we created a function handleScroll() that gets called whenever the scroll event happens. handleScroll() calculates whether it's time to load new chapters by simply analyzing if you scrolled until the last item in the list. And if you did, onLoadMore() gets called and, provided there are other items, a GraphQL query is sent.

The entire flow in our React application with pagination created with Apollo is this:

  1. ChapterList gets rendered and runs onLoadMore() when the list is scrolled to the bottom.
  2. ChapterListQuery sends a GraphQL query, which gets resolved by apollo-link-state (no HTTP request is actually sent).
  3. apollo-link-state uses resolvers to slice a chunk of requested items and respond to the GraphQL query.
  4. New chapters are returned and updateQuery() in fetchMore() gets called to add the latest chapters.
  5. React re-renders the application to show new items.

That's how you implement offset-based pagination with infinite scroll in React using Apollo and apollo-link-state.

If you're looking for a developer or considering starting a new project,
we are always ready to help!