Super Simple List Virtualization in React with IntersectionObserver
Tags: react, typescript, performance
Published: 2021-05-12 04:27:47

_Want smoother scrolling, but having trouble getting react-virtualized or react-window to work in your app? Try this dead-simple drop-in virtualization technique instead._

## Some quick background

I run a popular [AI Art Generator App](https://creator.nightcafe.studio) that's built on React. A big part of the user experience is simply scrolling through the feed of AI generated art that other users - or you - have created using the app. I personally use a fairly low-end Oppo smartphone and I noticed that the more artworks I scrolled through, the more jittery the scroll became. That's because as more artworks are loaded (via infinite scroll), React is struggling to render them all at once in - or even close to - 17 milliseconds (60 frames per second).

## The standard solutions 

So what can be done about this? The seasoned React dev knows that this is a problem that requires virtualization.

But what is virtualization? Essentially it means only rendering the list items that are on - or near - the viewport. In other words - only render the visible items and skip the rest.

Virtualisation is simple in theory, but a bit harder in practice. There are two commonly used React libraries for implementing virtualization - [react-window](https://github.com/bvaughn/react-window) and [react-virtualized](https://github.com/bvaughn/react-virtualized). _Both_ of these libraries are maintained by [Brian Vaughn](https://github.com/bvaughn), who is also a member of the core React team at Facebook.

As an experienced React developer, I've dealt with this problem in the past, and I already knew about these two libraries. I also knew that while they _are_ great libraries, they are actually quite difficult to implement in many situations - particularly when your list items are of varying sizes, not in a 'flat' list, responsive height, in a responsive grid, or have other elements interspersed (E.g. advertisements).

I did spend a while trying to get react-virtualized (the more flexible of the two) working on my list items, but after a couple of hours of roadblocks, I wondered if there was an easier, simpler solution to my problem.

## Enter `IntersectionObserver`

[`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) is a browser API - available on all modern browsers - that provides a way to execute a callback when a HTML element intersects with a parent element, or the browser viewport itself. Put more simply, it can tell us when our list items are on (or near) the screen as the user scrolls down the page.

I knew about Intersection Observers, having previously used them as a way to lazy-load images (before `<img loading="lazy" />` was a thing). Something made me think of this API while I was having virtualization woes, so I decided to see if it could solve my problems.

## The joy of simple lazy rendering

It took a little while to read through the `IntersectionObserver` spec and think about how I could React-ify it in a way that would suit my lazy-rendering use-case, but surprisingly, I encountered very few issues and quickly ended up with a super simple React component that I called `<RenderIfVisible />` which I could simply wrap around my list items at _any depth_ (no need for a flat list), to defer rendering until the item is near the viewport, then go back to rendering a plain div when the item leaves the viewport.

While it does have a couple of drawbacks, which I'll list a bit later, it comes with these advantages over react-virtualized or react-window:

- No need for a flat list
- Works with any DOM nesting structure
- Is completely decoupled from infinite-scroll or pagination
- Works for responsive grids with no extra configuration
- Easy to drop in - just wrap your list items with `<RenderIfVisible></RenderIfVisible>`
- Doesn't require a wrapper around your entire list
- Doesn't care how scrolling works for your situation (i.e. is it window scroll, or scrolling within a div with `overflow: scroll`)
- It is _tiny_ - __46 lines__ and has _no dependencies_ (apart from React as a peer dependency).

## Where can I get it?

On Github...

{% github NightCafeStudio/react-render-if-visible no-readme %}

Or [install it via npm](https://www.npmjs.com/package/react-render-if-visible)...

```bash
npm install react-render-if-visible --save
```

or yarn.

```bash
yarn add react-render-if-visible
```

## Show me under the hood

```typescript
import React, { useState, useRef, useEffect } from 'react'

const isServer = typeof window === 'undefined'

type Props = {
  defaultHeight?: number
  visibleOffset?: number
  root?: HTMLElement
}

const RenderIfVisible: React.FC<Props> = ({
  defaultHeight = 300,
  visibleOffset = 1000,
  root = null,
  children
}) => {
  const [isVisible, setIsVisible] = useState<boolean>(isServer)
  const placeholderHeight = useRef<number>(defaultHeight)
  const intersectionRef = useRef<HTMLDivElement>()

  // Set visibility with intersection observer
  useEffect(() => {
    if (intersectionRef.current) {
      const observer = new IntersectionObserver(
        entries => {
          if (typeof window !== undefined && window.requestIdleCallback) {
            window.requestIdleCallback(
              () => setIsVisible(entries[0].isIntersecting),
              {
                timeout: 600
              }
            )
          } else {
            setIsVisible(entries[0].isIntersecting)
          }
        },
        { root, rootMargin: `${visibleOffset}px 0px ${visibleOffset}px 0px` }
      )
      observer.observe(intersectionRef.current)
      return () => {
        if (intersectionRef.current) {
          observer.unobserve(intersectionRef.current)
        }
      }
    }
  }, [intersectionRef])

  // Set height after render
  useEffect(() => {
    if (intersectionRef.current && isVisible) {
      placeholderHeight.current = intersectionRef.current.offsetHeight
    }
  }, [isVisible, intersectionRef])

  return (
    <div ref={intersectionRef}>
      {isVisible ? (
        <>{children}</>
      ) : (
        <div style={{ height: placeholderHeight.current }} />
      )}
    </div>
  )
}

export default RenderIfVisible
```

Yep, that's the whole thing! Let me describe the important parts.

- We pass a `defaultHeight` prop which is an estimate of the element's height. This only used when the element is _not_ visible, and helps to avoid erratic scrollbar resizing.
- We also pass a `visibleOffset` prop, which tells the component how far outside the viewport to start rendering. The default is 1000, which means elements will render when they're within 1000px of the viewport.
- We keep two pieces of state: `isVisible`, which is used to trigger re-renders and render either the `{children}` or the placeholder; and `placeholderHeight` which we keep in a `ref` (to avoid causing re-renders) - we keep the `defaultHeight` here and update it with the actual calculated height when the element becomes visible.
- When the component renders for the first time, the component gets access to the wrapping element in the `intersectionRef` ref. It then sets up an `IntersectionObserver` to observe this element and toggle the `isVisible` state when the observer's callback is fired. This is done in [`window.RequestIdleCallback`](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback) (if possible) to avoid rendering off-screen (but within 1000px of the viewport) components when other important main thread work is being done.
- In the return from our `useEffect`, we call `unobserve` on the observer, because we are good citizens.
- We have another `useEffect` that runs when `isVisible` is toggled. If the component is visible, we update the `placeholderHeight` ref with the calculated height of the visible element. This value is kept in a ref (rather than react state) so that it doesn't cause the component to re-render. When `isVisible` is toggled back to false, the placeholder will use the calculated height.
- The component returns either the `{children}` or the placeholder element depending on the value of `isVisible`.

## Results from use in production

I've been using this component throughout [NightCafe Creator](https://creator.nightcafe.studio) for 9 months now (according to my commit history), and haven't noticed any scrolling jank or performance issues in that time. On screens where my Oppo smartphone used to struggle massively, I can now scroll smoothly through hundreds of artworks.

## What about those drawbacks?

First, when I say drawbacks, I don't mean drawbacks compared to _no_ virtualization, I mean drawbacks compared with other virtualization libraries. I think these drawbacks are very minor, but I'm listing them here for you anyway.

First, we end up with extra containing `<div>`s in our markup. These are required for setting placeholder height and attaching the observer.

Also, a new `IntersectionObserver` is created for every element that you wrap in `<RenderIfVisible></RenderIfVisible>`. This does result in some extra performance overhead - especially if there are hundreds or thousands of items. I can scroll through hundreds or thousands of items on my mid-tier smartphone without noticing any degradation, so this hasn't bothered me so far. However if you really need the absolute best performance of any solution, you might be better off using [react-window](https://github.com/bvaughn/react-window) and spending some extra time to get it working with your setup.

## Conclusion

`IntersectionObserver` offers a simple, native way to detect when HTML elements are on or near the viewport, and `<RenderIfVisible />` is a very simple and easy-to-implement component to harness that power to speed up the performance of long lists in your React app.

I hope this component helps you get some quick performance wins. Questions or feedback? Let me know in the comments!