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
offsetandlimitarguments to control the range of items that it returns. Thelimitis the maximum number of items returned by each query, and theoffsetis 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 = 1and2 % 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.