Guanlun Zhao
Web developer and learner who occasionally writes.
Find me on Github: github.com/guanlun
React List Scrolling Performance Tips

Say you have a list / grid with thousands of items, what can you do to keep the scrolling smooth? Recently at work we encountered a problem with an asset explorer that contained around 6,000 assets and we expect the user would scroll up and down all the time to find the assets they want. That makes scrolling performance critical to the user experience. A simple test that renders all the items at once quickly ruled out the brute-force approach.

Let’s take a deeper look at the use case first. The asset grid is a child of the asset explorer. Above the list we have some fancy graphics that changes as the user scrolls down (some sort of parallax scrolling stuff) so we need the onWheel listener at the container level.

A Simple Illustration

We then took a look at rendering only the visible items by adding a container component (let’s call it ViewportScrollProxy). We put the item inside the ViewportScrollProxy and based on its relative position, and render null if the item is out of the viewport.

Does it work? Well, partially. After applying this change the scrolling is still a bit janky so I opened the chrome devtools and did a timeline profiling. It seemed that checking which of the 6,000 are within the viewport every time a scroll event is fired is putting some serious load on the CPU. How do we effectively reduce the load when scrolling then? List virtualization comes to rescue.

List virtualization means we do not have a DOM element for each item to display. Instead, we only maintain a handful of <div>s enough to fill in our grid view. For our asset explorer we’re going to call them GridItems. the GridItems are reused to display different asset thumbnails. As we’re scrolling up and down, the items get updated and rendered in the grid view.

There are a few nice libraries available for virtualized lists and grids in react, e.g. react-virtualized. However it comes with default stlyes that we don’t want. Besides, it’s quite a heavy-weight library and we’d not like to see additional MBs added to our already large codebase to slow down our app loading time even further. So why not do it ourselves? It’s just a list view that listens to the scroll event, updates some position properties and re-populates the child components, right?

So that’s what we have. Based on the height of the container we determine the number of GridItems needed to fill it. Say its height is 1000px with 180x180 items (which means we need Math.ceil(1000 / 180) + 1 = 7 rows and 7 * 3 items). Explorer listens to the wheel event, update the fancy landing graphics and passes the scrollTop prop to the GridView. GridView takes a look at scrollTop, determines how many rows have been scrolled (i.e. number of rows above the first visible row, we call it numRowsAbove) and applies a paddingTop css property to position itself inside the viewport. If each row has 3 items and 4 rows are above, we start from the 13th item and populate 21 items into the GridItems.

Can we do it better? Do we need to re-render GridView for every wheel event? Looks like we don’t. The only thing we need to update in GridView is paddingTop. That essentially means we should not pass props to GridView upon each wheel. We simply need to dispatch an action ExplorerAction.scrollExplorerwhich calls ExplorerStore.scrollExplorer which emits EXPLORER_SCROLL_EVENT which is listened by GridView (phew~). And its the handleExplorerScroll function, we calculate numRowsAbove and call setState (notes that we used to do that in render!). This essentially saves us from invalidating the current view every single frame. Now we only re-render the GridView and re-populate the children every time we scroll past an entire row (of course we need to use shallowCompare to skip the unnecessary updates).

The result is encouraging. We’re now able to reach around 60FPS!

One additional and probably subtle performance improvement could be realized by replacing the paddingTop: 100px with transform: translateY(100px). However we’d need to controll the offset ourselves then, since we might scroll beyond the bound. I’m not going to elaborate in this article.

comments powered by Disqus