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 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 tiny - 46 lines and has no dependencies (apart from React as a peer dependency).
Where can I get it?
On Github...
NightCafeStudio / react-render-if-visible
Harness the power of Intersection Observers for simple list virtualization in React
Or install it 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; andplaceholderHeight
which we keep in aref
(to avoid causing re-renders) - we keep thedefaultHeight
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 anIntersectionObserver
to observe this element and toggle theisVisible
state when the observer's callback is fired. This is done inwindow.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 callunobserve
on the observer, because we are good citizens. - We have another
useEffect
that runs whenisVisible
is toggled. If the component is visible, we update theplaceholderHeight
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. WhenisVisible
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 ofisVisible
.
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!
Top comments (1)
Great article and thought process, Angus. I've been researching virtualization, specifically for a chat thread with reverse infinite scroll, for a year. I tried all the virtualization libraries -- all of them, including the ones that claimed to be better than all the others. None of them worked without an unbearable bug or janky scroll.
I ended up abandoning the rewrite of our chat from react virtual list. I started to look into using intersection observer right before abandoning the project due to time constraints. I'm now back in the market and am convinced intersection observer is the only reasonable path forward if you don't want to endure janky scroll (bad user experience) or spend half a year reinventing the wheel of implementing theoretical list virtualization and in the context of reverse infinite scroll, which most existing libraries don't support.
The only question now is how to beat optimize the intersection observer approach? Is it okay to have a wrapper div for every entry to present a placeholder when out of view, or should I batch the entries to minimize placeholder elements? Should I use a single intersection observer to observe all my entries, or is it fine to just go with a new observer for each entry? These are the kinds of questions I'm researching before I dive into implementation.
The easier options are probably fine, especially judging by your implementation and feedback that it's not causing noticeable performance issues.
Thanks again for sharing your story.