May 20, 2019
Adding Infinite Scroll For Both Desktop and Mobile in Your Gatsby Project with React Hooks
I recently created my second production Gatsby application that gives a simple presentation of a local government open data dataset. I say production, though, much of the application is a proof-of-concept for a bigger application I have in the works (perhaps a startup in the mix? Not sure yet...). My app includes over 2400 nodes, so I needed a way to present the data in user-friendly ways. Each record in my collection included a set of categories, so I could easily create a categories page and split the data that way. However, I also want to eventually add search and also allow for a user to browse through the entire dataset. This is where I looked into pagination
and infinite-scroll
. You can read about adding pagination on the Gatsby blog—it’s pretty straight-forward. Below is how I set up infinite-scroll that works in both development and production environments, as well as both in the browser and on touch screens. I was able to accomplish this using React Hooks
within a functional component rather than a class-based component.
Infinite Scroll & React Hooks
As much as this post is about integrating this within Gatsby, this is properly a react
question. Gatsby is simply a platform for sourcing data. This could easily be implemented via fetch
-ing of data from an API during client-side component. Adjust this method to what you need in your case.
Setting Up gatsby-node.js
Within gatsby-node.js
you will define an export named createPages
that queries graphql
for nodes from your data source and returns that list of nodes. Before returning, you can call the createPage
API as many times a you need to generate your site. I intend, among many other things, to create a single page that generates and infinite scroll through my list of businesses. I will call createPage
, passing to it the path of the page, the template for the page, and data that will be provided to the client via the Context
api:
exports.createPages = ({ actions, graphql }) => {
const { createPage } = actions
return graphql(`
{
#some query specific to your source data
specificNameOfYourQuery {
edges {
node {
#specific fields
}
}
}
}
`).then(result => {
if (result.errors) {
return Promise.reject(result.errors)
}
const { data: [specificNameOfYourQuery]: edges } } } = result;
const infiniteScrollTemplate = path.resolve(`src/templates/infinite-scroll-template.js`) createPage({ path: "/businesses", component: infiniteScrollTemplate, context: { edges, }, }) return edges;
})
Creating the Template that Will Include Infinite Scroll
From the root of your project, go into your src
directory, create a templates
folder if it doesn’t already exist, then create your template page. I entitled mine infinite-scroll-template.js
.
cd src
mkdir templates
cd templates
touch infinite-scroll-template.js
React Hooks - useEffect
and useState
Once you have opened your template file within your IDE, you will need to import React
as well as the useEffect
and useState
hooks. For Gatsby projects, you will also import your Layout
component so that your page will match the rest of your site. The createPage
API passes to your component pageContext
as props where you can access the list of edges
you pass to your template from gatsby-node
.
import React, { useState, useEffect } from 'react'
import Layout from '../components/Layout'
function InfiniteScroll({ pageContext: { edges } }) {
return null
}
function InfiniteScrollTemplate(props) {
return (
<Layout {...props}>
<InfiniteScroll {...props}/>
</Layout>
)
}
export default InfiniteScrollTemplate
We will focus on adding the core logic for infinite scroll to the InfiniteScroll
functional component. We do not need to declare a React
class because of the two aforementioned hooks—useState
and useEffect
Creating and Setting Internal State with useState
React hooks allow us to write functional components that can “hook into” other React features. useState
allows us to add state that is preserved between renders of a functional component. Unlike state
within a React class
, useState
replaces the previous state rather than merging with previous state. Calling useState
takes only one argument, whatever you conceive of as the initial state. The useState
hook can be called multiple times, so rather than having a single state
object, you can have multiple state
-like variables. This is because the call to useState
returns an array of two properties - the value of the current state and a function to call to update the value of that state.
const [currentState, setState] = useState(/* some value or fn that returns a value */)
For infinite scroll to work in this example, we need two state variables—a boolean
indicating if there are more records to load and an array
of the records already loaded. Seed the currentList
with the first 10 records. Don’t worry if there is the possibility that the initial set is less than 10, Array.slice
will return all records up to the length of the array if you provide an ending value greater than the last index of the array.
Note: if we had a situation where you loaded data asynchronously from an API, we would also need someway to determine if data was in loading state
const [ hasMore, setMore ] = useState(edges.length > 10)
const [ currentList, addToList ] = useState([...edges.slice(0, 10)])
// and if loading from an API asycrhonously
const [ isLoading, setLoading ] = useState(false)
Creating Event Handlers to Read and Set State
Reading and setting state will occur within an event listener on the scroll position of the page. If you use an external api and that api is still loading content, we will return immediately, and we will also exit if we know there are no more edges to load. Otherwise, we will check to see if the scroll position of the document
plus the innerHeight
of the window equals the offsetHeight
of the document, and if so, we can load more edges. Basically, this checks to see if the page is scrolled all the way to the bottom.
const handleScroll = () => {
if ( !hasMore || isLoading ) return;
if ( window.innerHeight + document.documentElement.scrollTop === document.documentElement.offsetHeight ){
loadEdges(true)
}
}
The loadEdges
function will do the following, in order:
- if using an asynchronously api call, will set the loading flag to true
- determine if any more edges remaining
- slice a new chunk of edges and append to the current list
- if using an asynchronously api call, will return the loading flag to false
Since I’m loading from the Context
API, I will ignore steps 1 & 2 above.
const loadEdges = () => {
const currentLength = currentList.length
const more = currentLength < edges.length
const nextEdges = more ? edges.slice(currentLength, currentLength + 20) : []
setMore(more)
addToList([...currentList, ...nextEdges])
}
How would isLoading
be used?
Here is one overly simplistic example:
const loadEdges = async () => {
setLoading(true);
try {
const newEdges = await fetch('https://path/to/some/api')
const more = newEdges.length > 0
setMore(more)
addBusinesses([...currentList, ...nextEdges])
} catch(err) {
console.error({fetchNewEdgesError: err})
}
setLoading(false)
}
Checking The Scroll Position on Each Render with useEffect
The final step to initializing infinite scroll is the useEffect
hook. The hook useEffect
takes two arguments: a function, and an array. The first argument is the function that will be called every time after the component is rendered. This function is allowed to return another function which will be remembered and gets called as a cleanup function (I’m not sure I fully understand cleanups yet, but I think Dan Abramov does). The second argument is an array of dependencies that would prevent an effect from being called if the values of those dependencies are unchanged between renders. Infinite scroll will a function with a cleanup callback as well as the array full of dependencies to work.
useEffect(
() => {
/* function that gets called every time */
return () => {
/* cleanup function to be called */
}
}, [/* dependencies */])
useEffect
is a great place to initialize event listeners on the window
or document
, as well as to remove those listeners during cleanup, such as listening for scroll
events.
window.addEventListener('scroll', handleScroll)
And thus, the cleanup function:
window.removeEventListener('scroll', handleScroll)
This gives us an almost complete useEffect
function call, we will simply add our state variables to the dependencies array so that effect is only set or cleaned up when the variables change:
useEffect(
() => {
window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('scroll', handleScroll)
}
}, [hasMore, isLoading, currentList])
With state, event handlers, effects initialized, we are free to return the jsx
for the scrolling list. The following maps over the currentList
array. It also adds labels displaying the current state of the list:
return (
<> {/* shorthand for React.Fragment */}
<ul>
{
currentList.map(({node: { fields }}, idx) => {
return (
<li key={`fields-${idx}`} index={idx + 1}>
{
/* you will know the specifics here from how you load your data */
fields
}
</li>
)
})
}
</ul>
{
!hasMore &&
<div>All Businesses Loaded!</div>
}
{
hasMore &&
<div>Scroll Down to Load More...</div>
}
{
/* if using this flag, otherwise omit */
isLoading &&
<div>Loading...</div>
}
</>
)
Putting It All Together
Now we have a complete picture of the infinite scroll functional component. See below, but don’t leave yet, we still have to account for gatsby build
and mobile events.
import React, { useState, useEffect } from 'react'
import Layout from '../components/Layout'
function InfiniteScroll({ pageContext: { edges } }) {
const [ hasMore, setMore ] = useState(edges.length > 10)
const [ currentList, addToList ] = useState([...edges.slice(0, 10)])
const loadEdges = () => {
const currentLength = currentList.length
const more = currentLength < edges.length
const nextEdges = more ? edges.slice(currentLength, currentLength + 20) : []
setMore(more)
addToList([...currentList, ...nextEdges])
}
const handleScroll = () => {
if ( !hasMore || isLoading ) return;
if ( window.innerHeight + document.documentElement.scrollTop === document.documentElement.offsetHeight ){
loadEdges()
}
}
useEffect(() => {
window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('scroll', handleScroll)
}
}, [hasMore, currentList])
return (
<> {/* shorthand for React.Fragment */}
<ul>
{
currentList.map(({node: { fields }}, idx) => {
return (
<li key={`fields-${idx}`} index={idx + 1}>
{
/* you will know the specifics here from how you load your data */
fields
}
</li>
)
})
}
</ul>
{
!hasMore &&
<div>All Businesses Loaded!</div>
}
{
hasMore &&
<div>Scroll Down to Load More...</div>
}
{
</>
)
}
function InfiniteScrollTemplate(props) {
return (
<Layout {...props}>
<InfiniteScroll {...props}/>
</Layout>
)
}
export default InfiniteScrollTemplate
This functional component will work just fine during development on a desktop browser. But you will lose the scroll effect during the build and on touch screens. It will fail during build because client globals like window
and document
are undefined during the build. It will fail on mobile because scroll
is a mouse event. We need to add some conditions for our event listeners to handle both conditions.
Optimizing for Production and Touch Screens
In addition to adding an event listener on scroll
, we also need event listeners for touchend
and resize
(to handle situations where someone resizes their browser), plus we need to add preventDefault
to touch events to prevent duplicate events being fired in certain situations where devices have both a mouse and a touch screen. First, create a new event handler for handling touchend
. This new handler will simply prevent the default actions on touchend
and call the handler for the scroll event (which simply checks to see if we should load more documents). Second, update the useEffect
function to add the additional event handlers.
const handleTouchEnd = (e) => { e.preventDefault(); handleScroll();}
useEffect(() => {
window.addEventListener('scroll', handleScroll)
window.addEventListener('resize', handleScroll) window.addEventListener('touchend', handleTouchEnd) return () => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleScroll) window.removeEventListener('touchend', handleTouchEnd) }
}, [hasMore, currentList])
Finally, we need to add a few simple boolean
checks (window &&
) to every instance of window
or document
so that the build process succeeds and so that infinite scroll still operates in the client. Plus, we need to change the scroll position check to be >=
instead of the strict equality ===
.
const handleScroll = () => {
if ( !hasMore || isLoading ) return;
if ( window && ( ( window.innerHeight + document.documentElement.scrollTop ) >= document.documentElement.offsetHeight ) ){ loadEdges()
}
}
useEffect(() => {
window && window.addEventListener('scroll', handleScroll) window && window.addEventListener('resize', handleScroll) window && window.addEventListener('touchend', handleTouchEnd) return () => {
window && window.removeEventListener('scroll', handleScroll) window && window.removeEventListener('resize', handleScroll) window && window.removeEventListener('touchend', handleTouchEnd) }
}, [hasMore, currentList])
There you have it! You have a component that will implement infinite scroll in the browser, on touch screens, and in production within a Gatsby or React application.
Check out the following page in your browser and on mobile to see this code in action.
You can also see my specific implementation of the source code on github.
Update
I had to adjust the touchend
handler to account for and exclude touches on links. See my next blog post on this topic.
Written by Wesley L. Handy who lives and works in Virginia Beach, VA, finding ways to build cool stuff in Gatsby and React. You should follow him on Twitter