Super Simple List Virtualization in React with IntersectionObserver

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.

Angus Russell
6 min readMay 12, 2021
Want that smooth scrolling on your lists? Read on!

Some quick background

I run a popular AI Art Generator App 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 and react-virtualized. Both of these libraries are maintained by Brian Vaughn, 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 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 tiny46 lines and has no dependencies (apart from React as a peer dependency).

Where can I get it?

Check out the code on Github:

Or just install via npm:

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

Or yarn:

yarn add react-render-if-visible

Show me under the hood!

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 (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 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 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!

--

--