As we discovered last time, traditional virtual scrolling implementations don’t work properly. Both React and the Chrome browser prioritize responsiveness when scrolling. New frames can be displayed before virtualized components have a chance to render updated content. Content appears torn or goes blank.

Luckily, I have a plan. Instead of rendering visible content into the scrollable area, we can render it into a separate component which is placed on top of the scrollable area. The browser can scroll away as much as it likes without impacting the displayed content. It only changes when we update it in response to scroll events. What’s displayed may be a frame behind the scroll bar position when interacting, but you don’t notice because what’s rendered is always consistent.

After lots of thought and experimentation, I’ve mirrored the decoupling of virtual scrolling and content display by breaking the functionality into separate components. This gives clients much greater flexibility in how they combine the components. Composition for the win!

I’ve also included pre-composed VirtualList and VirtualGrid components for simple use cases. These components are largely backwards compatible with previous versions of the package.

It’s time to introduce the new React Virtual Scroll 0.6.x component family.

react-virtual-scroll 0.6.0 release
×
react-virtual-scroll 0.6.0 release

Virtual Container

Let’s start simple. The previous generation of components used a common pattern. Components were typically implemented using nested divs with a fixed size outer viewport div and a larger inner div holding the content. The styling and rendering of these divs can be customized by passing in optional render props.

Each component had its own implementation of the pattern, with lots of repeated boiler plate code. Now, I’ve pulled the common code out into a common component.

VirtualContainer simply provides a div whose rendering can be customized via a render prop.

export type VirtualContainerRenderProps = React.ComponentPropsWithoutRef<'div'>;

export type VirtualContainerRender = (props: VirtualContainerRenderProps, ref?: React.ForwardedRef<HTMLDivElement>) => JSX.Element;

export interface VirtualContainerComponentProps extends VirtualContainerRenderProps {
  render?: VirtualContainerRender;
}

const defaultContainerRender: VirtualContainerRender = ({...rest}, ref) => (
  <div ref={ref} {...rest} />
)

export const VirtualContainer = React.forwardRef<HTMLDivElement, VirtualContainerComponentProps >(
  function VirtualContainer({render = defaultContainerRender, ...rest}, ref) {
    return render(rest, ref)
})

That’s all there is to it.

Display List

I gave an overview of DisplayList last time. It’s a controlled component that renders a window onto a virtualized list, starting from a specified offset.

There are two changes since then. I removed lots of boilerplate code by using VirtualContainer instead of a local implementation of a customizable container. The change is backwards compatible as the shape of the types hasn’t changed.

I also added an isScrolling prop which DisplayList just passes through to it’s rendered children. We’ll see why that’s useful later.

<VirtualContainer className={className} render={outerRender} style={outerStyle}>
    <VirtualContainer className={innerClassName} render={innerRender} style={innerStyle}>
    {sizes.map((_size, arrayIndex) => (
      <ChildVar isScrolling={isScrolling} {...rest}/>
    ))}
  </VirtualContainer>
</VirtualContainer>

Try pressing and holding the up and down arrows in the “Offset” field to get a good understanding of how the component works. You can also switch between horizontal and vertical layouts.

Display Grid

DisplayGrid is the same thing for grids. You get a rowOffset and a columnOffset instead of just one. Implementation is a simple extension of what DisplayList did to two dimensions.

<VirtualContainer className={className} render={outerRender} style={outerStyle}>
    <VirtualContainer className={innerClassName} render={innerRender} style={innerStyle}>
    {rowSizes.map((_rowSize, rowIndex) => (
      <Fragment key={itemKey(rowStartIndex + rowIndex, 0, itemData)}>
      {colSizes.map((_size, colIndex) => (
        <ChildVar data={itemData} isScrolling={isScrolling} style={boxStyle}
          key={itemKey(rowStartIndex + rowIndex, colStartIndex + colIndex, itemData)} 
          rowIndex={rowStartIndex + rowIndex}
          columnIndex={colStartIndex + colIndex}/>
      ))}
      </Fragment>
    ))}
  </VirtualContainer>
</VirtualContainer>

Press and hold the arrow buttons in the offset fields to move the window over the virtualized grid.

Virtual Scroll

VirtualScroll extracts the scrolling functionality from the old VirtualList and VirtualGrid. Implementation uses the standard approach of an outer viewport container holding an inner content container. The difference is that the outer container now has two children.

The content container uses sticky positioning to ensure that it always fills the viewport, regardless of scroll position. The other child is an empty div which acts as the scrollable area. Content rendering is decoupled from scrolling. The browser can no longer scroll stale content out of view.

<VirtualContainer className={className} render={outerRender} onScroll={onScroll}
    style={{position:"relative", height, width, overflow: "auto", willChange: "transform"}}>
  <VirtualContainer className={innerClassName} render={innerRender} 
    style={{zIndex: 1, position: 'sticky', top: 0, left: 0, width: '100%', height: '100%'}}>
    {children({isScrolling, verticalOffset, horizontalOffset})}
  </VirtualContainer>
  <div style={{ position: 'absolute', top: 0, left: 0, 
    height: scrollHeight ? renderRowSize : '100%', 
    width: scrollWidth ? renderColumnSize : '100%'}}/>
</VirtualContainer>

Scrolling behavior is determined by the scrollWidth and scrollHeight props. These directly set the size of the scrollable area. Set both for a two-dimensional scrolling experience, set either one for a horizontal or vertical scrolling experience.

export interface VirtualScrollProps extends VirtualScrollableProps {
  scrollHeight?: number,
  scrollWidth?: number,

  onScroll?: (verticalOffset: number, horizontalOffset: number, 
    newVerticalScrollState: ScrollState, newHorizontalScrollState: ScrollState) => void;

  children: VirtualContentRender
}

The inherited props are the common ones from the old VirtualList and VirtualGrid.

The onScroll callback is a superset of the old VirtualList and VirtualGrid onScroll callbacks. A children render prop is used to render content that should respond to the scroll position, making isScrolling, verticalOffset and horizontalOffset props available to the rendered children.

VirtualScroll, DisplayList and DisplayGrid are designed to work well together.

<VirtualScroll
  useIsScrolling={true}
  scrollHeight={scrollHeight}
  scrollWidth={scrollWidth}
  height={200}
  width={600}>
  {({ isScrolling, verticalOffset, horizontalOffset }) => (
    <DisplayGrid
      ...
      isScrolling={isScrolling}
      rowOffset={verticalOffset}
      columnOffset={horizontalOffset}>
      {Cell}
    </DisplayGrid>
  )}
</VirtualScroll>

This sample uses onScroll to display the detailed scroll position as you interact with the VirtualScroll component.

Try changing the scroll height and width and seeing how the scroll bars respond.

Auto Sizer

Components like DisplayList and DisplayGrid need to be given an explicit width and height so that they know what subset of the virtualized content needs to be rendered. What if you want the size to be dynamic and respond as the browser window is resized and the layout changes? That’s where AutoSizer comes in.

AutoSizer was inspired by react-virtualized-auto-sizer, a companion project to react-window. It provides a higher order component which measures its size, then passes explicit width and height props to its children.

I’ve implemented my own version in modern React. The original has an overly complex implementation and boundary issues. It subscribes to events on its parent and resizes its child to the area of its parent. There’s lots of edge cases because it can’t control its parent’s lifetime. At the same time, it imposes constraints on the parent. For example, it only really works if the parent has a single child. The documentation suggests the workaround of wrapping the component in a dedicated div.

I’ve gone for a much simpler implementation with a nested div setup. AutoSizer measures the size of the outer div. The outer div’s size is controlled by its parent in the usual way. There are no constraints on the parent and no unnatural interactions with it. Clients can apply whatever style they want to the outer div to influence the layout process.

The inner div is unusual. It has zero width and height with a visible overflow. Children are added to the inner div. It’s vital that children have no influence on the size of the outer div. It’s easy to end up with infinite loops if we pass a measured size to a child which in turn makes itself bigger which then increases the size of its parent. Wrapping the children in a zero size div ensures that the outer div ignores child sizes when determining its own size. The visible overflow ensures the children are visible.

<div ref={ref} className={className} style={style}>
  <div style={{ overflow: 'visible', width: 0, height: 0 }}>
  {children({height, width})}
  </div>
</div>

There’s no need to use VirtualContainer as these divs don’t need to support additional customization. AutoSizer does a very specific job as a higher order component. Any further customization would be applied to the parent or children instead.

Children are defined using a render prop with width and height parameters. This provides flexibility in how the measured width and height are used. You use an AutoSizer like this:

<AutoSizer style={{ height: '100%', width: '100%' }}>
{({height,width}) => (
  <DisplayList
    height={height}
    width={width}
    {...rest}>
    {Row}
  </DisplayList>
)}
</AutoSizer>

The implementation uses a simple layout effect to determine the initial size and to add a ResizeObserver to track future resizes. Resize observers are only available in a browser context so we take care not to crash if used in a unit test or server side rendering.

React.useLayoutEffect(() => {
  const div = ref.current;
  if (!div)
    return;

  setHeight(div.clientHeight);
  setWidth(div.clientWidth);

  if (typeof ResizeObserver !== 'undefined') {
    const resizeObserver = new ResizeObserver(resizeCallback);
    resizeObserver.observe(div);
    return () => { resizeObserver.disconnect() }
  }
}, [resizeCallback])

Great care is needed to avoid running the effect repeatedly, which would result in observers being repeatedly disconnected and recreated. We use separate width and height state as primitives are directly comparable by React. React will automatically short circuit subsequent renders if the size hasn’t changed. That avoids having to explicitly check whether width and height have changed, which in turn means there are no dependencies on state or props in the resize handler.

The end result is that the layout effect runs once on mount.

A sample app using AutoSizer is embedded above. It doesn’t do anything interesting if you can’t resize it, so best follow this link to run the sample on a dedicated page.

As usual, I aim for 100% code coverage in unit tests. I needed to add the jsdom-testing-mocks package to my development environment to provide a ResizeObserver mock as it’s not supported by jsdom.

Virtual List

Virtual List is a little more complicated than you might think. It’s been reimplemented as a combination of a VirtualScroll and a DisplayList. The DisplayList needs to be big enough to cover the VirtualScroll viewport client area. It can be larger but has to stay within the bounds of the scrollable area.

The viewport client area size depends on styling and whether scroll bars are visible. I initially set the DisplayList to the overall size of the VirtualScroll, deciding not to worry about it being slightly too large. Unfortunately, that doesn’t work in all cases.

For example, consider a list with a large scroll height that needs a vertical scroll bar displayed. The list content has minimal width. Over sizing the DisplayList will result in an unintended horizontal scroll bar.

The simplest solution is to wrap the DisplayList in an AutoSizer so that it exactly fits the client area.

<VirtualScroll
  scrollHeight={isVertical ? renderSize : undefined}
  scrollWidth={isVertical ? undefined : renderSize}
  {...otherScrollProps}>
  {({ isScrolling, verticalOffset, horizontalOffset }) => (
    <AutoSizer style={{ height: '100%', width: '100%' }}>
      {({height,width}) => (
        <DisplayList
          offset={isVertical ? verticalOffset : horizontalOffset}
          isScrolling={isScrolling}
          height={height}
          width={width}
          {...otherDisplayProps}>
          {ChildVar}
        </DisplayList>
      )}
    </AutoSizer>
  )}
</VirtualScroll>

Most of the remaining code is needed to maintain backwards compatibility with the onScroll callback and VirtualListProxy. There’s lots of forwarding and restructuring to and from VirtualScroll.

One of the reasons for decoupling functionality is to allow the client to put together their own combinations. To make it easier, I exported most of the VirtualListProxy functionality as utility functions that you can use with your own VirtualScroll. Which annoyingly means moving it into its own TypeScript source file to avoid breaking Vite HMR.

All worth it because the existing samples build and run unmodified. This is the trillion row list sample.

Virtual Grid

I think you know the drill by now. VirtualGrid = VirtualScroll + AutoSizer + DisplayGrid.

<VirtualScroll
  scrollHeight={totalRowSize}
  scrollWidth={totalColumnSize}
  {...otherScrollProps}>
  {({ isScrolling, verticalOffset, horizontalOffset }) => (
    <AutoSizer style={{ height: '100%', width: '100%' }}>
    {({height,width}) => (
      <DisplayGrid
        isScrolling={isScrolling}
        rowOffset={verticalOffset}
        columnOffset={horizontalOffset}
        height={height}
        width={width}
        {...otherDisplayProps}>
        {ChildVar}
    </DisplayGrid>
  )}
  </AutoSizer>
  )}
</VirtualScroll>

This is the trillion square grid sample. Super responsive scrolling. No more rendering glitches.

Upgrading

Upgrading in most cases is straightforward. I kept as much of the existing VirtualList and VirtualGrid interface as I could. In general all the samples and react-spreadsheet just worked without any code changes, despite massive internal changes.

The most likely thing to cause problems is outerRender and innerRender customization. The outerRender prop is used to customize how components interact with their parent. The innerRender prop is used to customize how components interact with their children. In principle the structure in between is free to change.

In practice there was one use of innerRender that caused problems. I previously created a “customized vertical list with padding” sample based on a react-window sample. The sample adds padding to the top and bottom of a VirtualList by modifying the layout of the list’s children. It assumes the children use absolute positioning and rewrites each child’s style to add an offset.

The innerRender prop is forwarded to the internal DisplayList. DisplayList uses a grid layout so the sample doesn’t work. You can’t implement equivalent hackery with the new VirtualList because you’d need access to the structure in between.

Luckily, there’s a better way. If you have more complex needs you can combine the basic components yourself. It turns out that you don’t need to make any assumptions about DisplayList internals.

<VirtualScroll
  scrollHeight={totalSize + PADDING_SIZE*2}
  {...otherScrollProps}>
  {({ verticalOffset }) => (
    <AutoSizer style={{ height: '100%', width: '100%' }}>
    {({height,width}) => (
      <DisplayList
        offset={verticalOffset - PADDING_SIZE}
        height={height}
        width={width}
        {...otherDisplayProps}>
        {Row}
      </DisplayList>
    )}
    </AutoSizer>
  )}
</VirtualScroll>

Overscan

Traditional virtual scrolling implementations, like react-window, use overscanning to try and compensate for glitchy rendering. Overscanning is the process of rendering additional content outside the visible window. If the browser scrolls stale content, the overscan items will be scrolled into view, reducing flashing. Obviously, this only works for small scale scrolls and ultimately reduces performance as you have more to render each frame.

With the new decoupled rendering approach none of this is needed. I removed all the overscan rendering from react-virtual-scroll.

Layout Shift

If you profile the new components using Chrome, you may see purple bars labeled as “Layout Shift”.

Performance tool frame capture
×
Performance tool frame capture

Layout shifts occur when content moves around on the page. They’re meant to identify user experience problems when content changes unpredictably, typically from images or data loading asynchronously and triggering a layout change.

In our case we’re intentionally changing the layout of a DisplayList or DisplayGrid to simulate the effects of scrolling. The weird thing is that Chrome only reports layout shifts when using the mouse wheel to scroll.

Cumulative Layout Shift

Cumulate Layout Shift is a core web performance metric. It calculates an abstract score based on the number and size of layout shifts during an interaction session. The intent is to measure only unintended layout shifts. Layout shifts within 500ms of user interaction are ignored. Unfortunately, continuous interactions such as scrolls are not considered to be recent input.

The Chrome dev tools performance tab shows web metrics for the current session. Here’s what they look like when interacting with the components using mouse and keyboard.

Web metrics during keyboard navigation
×
Web metrics during keyboard navigation

Here’s the metrics after some heavy duty mouse wheel scrolling.

Web metrics during mouse wheel scrolling
×
Web metrics during mouse wheel scrolling

I consider this to be a false positive. It only happens when the user is actively scrolling using the mouse wheel, so has no impact on CLS score during page load. CLS scores are important for many people as they are part of Google Search’s page experience based ranking.

React 18 Rendering

Decoupling virtualized content update from scrolling means that I no longer need to rely on the legacy React rendering API. All of these samples use the new React 18 API which gives continuous input events, like scrolling, a lower priority.

Performance tool frame capture
×
Performance tool frame capture

This is a frame from the middle of a sequence of scroll events. You can see that React returns to the event loop immediately after the scroll event is received, the browser paints the content from the previous frame and then React renders and updates the DOM for the new scroll position.

Play with the samples. You can’t tell that what you’re seeing is a frame behind because each frame is consistent.

Next Time

Next time we’ll see how we can make use of these new super powers in our React Spreadsheet.