May 20, 2019

Adding Infinite Scroll For Both Desktop and Mobile in Your Gatsby Project with React Hooks

List of items waiting to be updated on scroll

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:

  1. if using an asynchronously api call, will set the loading flag to true
  2. determine if any more edges remaining
  3. slice a new chunk of edges and append to the current list
  4. 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.

Wesley L. Handy

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