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
andlimit
arguments to control the range of items that it returns. Thelimit
is the maximum number of items returned by each query, and theoffset
is the number of items that should be skipped.
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});
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 resultlimit: 10 # limit to 10 launchessort: "launch_date_utc" # sort by launch date...order: "desc" # ...in descending order) {idmission_namerocket {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 clientconst 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! 🚀
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: $offsetlimit: $limitsort: "launch_date_utc"order: "desc") {idmission_namerocket {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.
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 statesreturn (<divstyle={{display: 'grid',gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',gridGap: 20}}>{data.launches.map(launch => (<divkey={launch.id}style={{padding: 12,background: colorHash.hex(launch.id)}}><h2>{launch.mission_name}</h2><h3>{launch.rocket.rocket_name}</h3></div>))}</div>);}
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.
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 statesreturn (<>{/* grid of launches */}<InViewonChange={inView => {if (inView) {fetchMore({variables: {offset: data.launches.length}});}}}/></>);}
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>;}
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:
fetchMore
query is not currently in progressDetermining 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
and2 % 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 statesreturn (<>{/* grid of launches */}<InViewonChange={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 statesreturn (<>{/* grid of launches */}{networkStatus !== NetworkStatus.fetchMore &&data.launches.length % variables.limit === 0 &&!fullyLoaded && <InView onChange={/* handle visibility change */} />}</>);}
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:
Below you can find a complete working example of all of these techniques working together to create an infinitely scrollable grid of SpaceX launches.