Implementing infinite scroll in React
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> ))} </> ) }