Implementing infinite scroll in React - Phelipe Teles

Implementing infinite scroll in React

2 min.
View source code

Infinite scroll is a common UX that web developers need to be comfortable implementing. In this post, we’ll see how to implement this experience using standard web APIs such as IntersectionObserver and React hooks.

Introduction to IntersectionObserver

This is a standard web API that, as the name suggests, let us observe when an element in our web page intersects with another.

In practice, we are interested in observing, for example, when the last row of a table becomes visible to the user — when it “intersects” with the “viewport”.

In the example below, we make the element’s background turn green when it becomes visible:

import { useState, useRef, useEffect } from 'react'

export default function App() {
  const [isIntersecting, setIsIntersecting] = useState(false)

  const target = useRef(null)

  useEffect(() => {
    if (!target.current) {
      return
    }

    const observer = new IntersectionObserver(([entry]) =>
      setIsIntersecting(entry.isIntersecting)
    )

    observer.observe(target.current)

    return () => {
      observer.unobserve(target.current)
    }
  }, [target])

  return (
    <div
      ref={target}
      style={{
        display: 'grid',
        placeItems: 'center',
        backgroundColor: isIntersecting ? 'green' : 'red',
        transition: 'background-color 500ms ease-in-out',
        height: 100,
      }}
    >
      I'm being observed
    </div>
  )
}

We can extrapolate this example to build an infinite scroll experience — the major difference here would be what to do once the element comes into view: instead of changing the element’s color, we would trigger a network request etc.

Introducing react-inteserction-observer library

The code in the previous example is not as expressive as I’d like. I’m also never quite 100% sure if I’m doing things correctly in useEffect — is my that all the code I need to write in clean up function? Is there any potential memory leaks? I don’t think there is but I’m not quite sure either.

Because of this, I prefer to use a library for this. It turns out there is an library, react-inteserction-observer, that lets you use the IntersectionObserver API in a idiomatic way in React.

Let’s re-write the previous example using its useInView hook:

import { useInView } from 'react-intersection-observer'

export default function App() {
  const { ref: target, inView } = useInView()

  return (
    <div
      ref={target}
      style={{
        display: 'grid',
        placeItems: 'center',
        backgroundColor: inView ? 'green' : 'red',
        transition: 'background-color 500ms ease-in-out',
        height: 100,
      }}
    >
      I'm being observed
    </div>
  )
}

This code is more readable, idiomatic and I’m more confident with it — I don’t have to worry about useEffect clean up functions and memory leaks.

Comparison with other libraries

I also like that react-inteserction-observer is just a thin wrapper around IntersectionObserver. It has a small API surface and it does not do much more than the web standard API — it just translate it into the React way of doing things.

For comparison, some people may prefer to use a library like react-infinite-scroll-component or react-infinite-scroller, which I dislike because they fall into the God-like components — a much opinionated abstraction with customization done entirely by props.

Implementing infinite scroll

With loading indicator

If our UI has a loading indicator, like a spinner, we can just pass the ref returned by useInView hook to it. The onChange callback will be executed when the spinner gets into view to handle getting more items:

import { useState } from 'react'
import { useInView } from 'react-intersection-observer'
import { getRange } from './getRange.js'

export default function App() {
  const [lastNumber, setLastNumber] = useState(10)

  const numbers = getRange(0, lastNumber)

  const { ref: target, inView } = useInView({
    onChange: (inView) => {
      if (inView) {
        setLastNumber(lastNumber + 10)
      }
    },
  })

  return (
    <>
      {numbers.map((number) => (
        <div>{number}</div>
      ))}

      <div ref={target}>Loading...</div>
    </>
  )
}

Without loading indicator

If our UI doesn’t have a loading indicator, we can use the ref as a callback refs and call it with the last item in the list:

import { useState } from 'react'
import { useInView } from 'react-intersection-observer'
import { getRange } from './getRange.js'

export default function App() {
  const [lastNumber, setLastNumber] = useState(10)

  const numbers = getRange(0, lastNumber)

  const { ref: target, inView } = useInView({
    onChange: (inView) => {
      if (inView) {
        setLastNumber(lastNumber + 10)
      }
    },
  })

  return (
    <>
      {numbers.map((number, index) => (
        <div
          ref={(node) => {
            if (index === numbers.length - 1) {
              target(node)
            }
          }}
        >
          {number}
        </div>
      ))}
    </>
  )
}