t

Trevor Blades

AboutProjectsLabOSS
Subscribe

Infinite Scroll With Apollo Client 3

How to implement infinite scrolling in React and GraphQL

Infinite scroll is a web design technique that loads more content as the user scrolls towards the end of the loaded content. It creates the effect of a never-ending stream of content and can provide a more fluid alternative to conventional pagination. You can find examples everywhere on the web, like when you scroll through posts on Twitter or Instagram or view search results on sites like Giphy.

In this post, I'm going to demonstrate how to use Apollo Client 3 to create an infinite scroll effect using data from a GraphQL API. You can use any GraphQL API to do this, but for this example, I'm going to use the SpaceX Land API because it's free and open, and uses offset-based pagination.

💡 Did you know?

Offset-based pagination is a pagination strategy where a field accepts offset and limit arguments to control the range of items that it returns. The limit is the maximum number of items returned by each query, and the offset is the number of items that should be skipped.

Set up Apollo Client

Before making queries, we need to set up our Apollo Client instance. First, install the @apollo/client and graphql packages.

npm i @apollo/client graphql

Next, create a new instance of ApolloClient and pass in the URL of the GraphQL API and configure the cache. We'll use the Apollo InMemoryCache for this example. For more information about caching in Apollo Client, check out this article from the docs.

import {ApolloClient, InMemoryCache} from '@apollo/client';
const cache = new InMemoryCache();
const client = new ApolloClient({
uri: 'https://api.spacex.land/graphql/',
cache
});

Query for launches

We now have a GraphQL client ready to accept queries. Let's write a query that lists out the last 10 SpaceX launches. This query grabs the name of the mission and the type of rocket that pulled it off.

query ListLaunches {
launches: launchesPast(
offset: 0 # start at the first result
limit: 10 # limit to 10 launches
sort: "launch_date_utc" # sort by launch date...
order: "desc" # ...in descending order
) {
id
mission_name
rocket {
rocket_name
}
}
}

To execute this query in the browser, we can use the useQuery hook from Apollo Client. Within a React component, call the useQuery hook and pass the query from above, wrapped in a gql template literal tag, as the first argument. Pass the client instance created earlier as an option to the second argument of useQuery. Alternatively, you could wrap your entire app in an ApolloProvider component and pass it the client instance to avoid passing it to each Apollo hook.

import {gql, useQuery} from '@apollo/client';
// configure cache and client
const LIST_LAUNCHES = gql`
query ListLaunches {
# same query from above
}
`;
function ListLaunches() {
const {data, loading, error} = useQuery(LIST_LAUNCHES, {client});
if (loading) {
return <div>Loading...</div>;
}
if (error) {
return <div>{error.message}</div>;
}
return (
<ul>
{data.launches.map(launch => (
<li key={launch.id}>{launch.mission_name}</li>
))}
</ul>
);
}

At this point, we have a component that grabs the last 10 launches and renders them in a list. We're off to a flying start! 🚀

Make it dynamic

In preparation for infinite scrolling need to make the query dynamic, meaning its offset and limit arguments must be configurable by variables. Variables are defined in the query definition, prefixed with a $ and mapped to a GraphQL type.

query ListLaunches($offset: Int!, $limit: Int!) {
launches: launchesPast(
offset: $offset
limit: $limit
sort: "launch_date_utc"
order: "desc"
) {
id
mission_name
rocket {
rocket_name
}
}
}

These variables can be supplied to the query via the variables option passed to the second argument of useQuery.

const {data, loading, error} = useQuery(LIST_LAUNCHES, {
client,
variables: {
offset: 0,
limit: 10
}
});

The component should work the same as before, but now we're able to change the offset or limit variables to load different "pages" of data.

From a list to a grid

Many examples of infinite scrolling on the web display their results in a grid, so I'm going to take a moment to turn our plain old list into a nice, colorful grid of results. You can skip ahead to the next section if styling is of little interest to you.

Instead of choosing colors for each launch, I opted to use color-hash to pick a random color based on the ID of each launch.

npm i color-hash

Then we can turn the ul into a CSS grid by giving it display: grid and configuring the column width using the grid-template-columns CSS property.

I'm using the auto-fit keyword here, combined with the minmax function to say "fit as many grid cells as possible into each row as long as each cell is no smaller than a minimum width". In the example below, I've set a minimum width of 150 pixels. For more information about auto-fit and minmax, check out this guide from CSS-Tricks.

I set a grid-gap of 20 pixels to maintain space between the grid cells, and gave each grid cell 12 pixels of padding. I also set the background color of each grid cell using the colorHash.hex function and passing the launch ID as an argument.

import ColorHash from 'color-hash';
const colorHash = new ColorHash();
function ListLaunches() {
// useQuery hook and loading/error states
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gridGap: 20
}}
>
{data.launches.map(launch => (
<div
key={launch.id}
style={{
padding: 12,
background: colorHash.hex(launch.id)
}}
>
<h2>{launch.mission_name}</h2>
<h3>{launch.rocket.rocket_name}</h3>
</div>
))}
</div>
);
}

Fetch more results

To fetch additional pages of data, we can call the fetchMore function returned by useQuery. This function allows us to update the variables that were originally passed to our query and merges the results of each subsequent query with the existing data returned by the hook.

For Apollo Client to merge this data propertly, we must configure a field policy for the launchesPast field in our Apollo Client cache. Since our API is using offset-based pagination, we can use the offsetLimitPagination field policy that comes with Apollo Client.

💡 Did you know?

For more complex pagination approaches, you might need to define a custom field policy. This article from the Apollo docs goes into more detail about this topic.

import {InMemoryCache} from '@apollo/client';
import {offsetLimitPagination} from '@apollo/client/utilities';
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
launchesPast: offsetLimitPagination()
}
}
}
});

Now we can use the fetchMore function and update the offset variable to the number of results that have already been loaded. This will fire off a new query with a new offset and the same limit configured earlier.

fetchMore({
variables: {
offset: data.launches.length
}
});

We want to call fetchMore every time the user scrolls to the bottom of the list, or if there's more space on the page to load additional pages of results. To do this, we can use the Intersection Observer API.

Intersection Observer

Historically, we might attach an event handler to the window's scroll event and determine if the current scroll position is greater than or equal to the vertical position of the end of our list of results. This method requires a calculation to be made every time the user scrolls, even if they're far from the end of the page.

Intersection Observer, on the other hand, will broadcast an event only when an element enters or exits the bounds of the browser viewport. This is a much lighter-weight approach and the one that I'll be using in this example.

The react-intersection-observer library is a React implementation of the Intersection Observer API that will tell us when an element comes into or out of view.

npm i react-intersection-observer

It comes with a useInView hook and an InView component. Pick your poison. 🧪 In this example, I'll use the InView component and listen for changes using its onChange prop.

import {InView} from 'react-intersection-observer';
function ListLaunches() {
// useQuery hook and loading/error states
return (
<>
{/* grid of launches */}
<InView
onChange={inView => {
if (inView) {
fetchMore({
variables: {
offset: data.launches.length
}
});
}
}}
/>
</>
);
}

Network status

By default, when fetchMore is called, it will set the loading property returned by useQuery to true. This will cause our loading state to flash every time a new page is loaded—not ideal.

To differentiate between the initial load and subsequent fetches, you must tell Apollo to send more fine-grained network statuses by setting the notifyOnNetworkStatusChange option in useQuery. We can then analyze the networkStatus property returned by the hook to tell what type of load is happening.

const {data, networkStatus, error} = useQuery(LIST_LAUNCHES, {
client,
notifyOnNetworkStatusChange: true,
variables: {
offset: 0,
limit: 10
}
});

The previous loading state can be refactored to consider this new networkStatus property. Use the NetworkStatus enum to compare the networkStatus with a conveniently named property. Check out the source for NetworkStatus to learn more about all of the possible network statuses.

import {NetworkStatus} from '@apollo/client';
if (networkStatus === NetworkStatus.loading) {
return <div>Loading...</div>;
}

Render the loader

The InView component will function as our "loader" in this example. When it's visible, more results will be loaded. To avoid over fetching, this component should only be rendered under if the following conditions are met:

  1. A fetchMore query is not currently in progress
  2. We know there are still additional pages of data to load
  3. We haven't already loaded all of the available pages

Determining the first condition is easy. Compare the network status with the value of NetworkStatus.fetchMore.

const isFetchingMore = networkStatus === NetworkStatus.fetchMore;

For the second, we can tell if the most recently loaded page is the last one because it will have contained fewer results than the limit configured in our query. This can be tested by comparing the total number of launches and the configured limit using the modulo operator.

💡 Did you know?

The modulo operator (%) returns the remainder of a division after one number is divided by another. For example, 3 % 2 = 1 and 2 % 2 = 0.

The useQuery hook returns a variables property that reflects the configured query variables. We can assume there are more pages to load if the total number of launches divides evenly into the limit.

const {data, networkStatus, error, variables} = useQuery(/* query options */);
const isFullPage = data.launches.length % variables.limit === 0;

But what if the last page of results contains the same number of results as our limit? To account for this, we can check if fetchMore returns an empty list of results, and set some React state indicating that we're fully loaded.

fetchMore returns a promise that resolves to the result of that individual query. In the example below, I make the onChange handler an async function and await the result of fetchMore. Then I set the fullyLoaded state to true if the returned data contains no launches.

import {useState} from 'react';
function ListLaunches() {
const [fullyLoaded, setFullyLoaded] = useState(false);
// useQuery hook and loading/error states
return (
<>
{/* grid of launches */}
<InView
onChange={async inView => {
if (inView) {
const result = await fetchMore(/* update variables */);
setFullyLoaded(!result.data.launches.length);
}
}}
/>
</>
);
}

Putting these three conditions together, our loader can be conditionally rendered like so:

function ListLaunches() {
// fullyLoaded state, useQuery hook, and loading/error states
return (
<>
{/* grid of launches */}
{networkStatus !== NetworkStatus.fetchMore &&
data.launches.length % variables.limit === 0 &&
!fullyLoaded && <InView onChange={/* handle visibility change */} />}
</>
);
}

Conclusion

That's it! In this post, I showed you how to implement infinite scroll using Apollo Client 3. To recap, we had to do the following for this feature to work:

  • Configure an Apollo Client cache with a field policy to ensure data gets merged together properly
  • Use the Intersection Observer API to tell when the user has reached the end of the list of results
  • Fetch more results by updating query variables
  • Analyze fine-grained network statuses to render loading states and control subsequent fetches
  • Determine when the full set of data has been loaded to avoid unnecessary fetches

Below you can find a complete working example of all of these techniques working together to create an infinitely scrollable grid of SpaceX launches.

Made with 🥥 in Burnaby, BC
© 2022 - Source code