DEV Community

Cover image for React Virtual Window - virtualise anything for a performance boost!
Mike Talbot
Mike Talbot

Posted on • Updated on

React Virtual Window - virtualise anything for a performance boost!

TLDR;

I've made a new React Virtual Window component that will virtualise lists and any child React component. This article describes how it works.

Have a look at the demos to check it out.

If you'd just like to use it then:

You can install it from npm

npm i virtual-window
Enter fullscreen mode Exit fullscreen mode

and import it

import { VirtualWindow } from 'virtual-window'
Enter fullscreen mode Exit fullscreen mode

And use it:

function MyComponent({list}) {
    return <VirtualWindow>
      <MyComponent1/>
      {list.map(l=><SomeComponent key={l.id} data={l} />)}
      <MyLastComponent/>
   </VirtualWindow>

}
Enter fullscreen mode Exit fullscreen mode

Or on lists by supplying an item to render

function MyOtherComponent({list}) {
   return <VirtualWindow pass="data" list={list} item={<SomeComponent/>}/>
}
Enter fullscreen mode Exit fullscreen mode

Introduction

I recently wrote about making a <Repeat/> component for React that allows you to construct components that have repeated elements without cluttering up the layout with {x.map(()=>...)}. While that concept is useful and reduces the fatigue associated with understanding components, it's really just "sugar".

The real power of a "<Repeat/>" is when you can use it to enable really vast lists without slowing down React, by virtualising them. In other words, only render the parts of the list that you must in order for the screen to be complete and don't bother with the other 800,000 items that would really slow React down :)

There are a number of virtual list open source projects out there (including one by me!) However, they all lack something I need or are just "black boxes", so I thought it was time to revisit the principle and see if I could make a smaller, more powerful and simpler version that meets a set of requirements I've found in many projects. The end result is simple enough for me to describe in detail in a Dev post, which is a bonus - no chance I'd have been doing that with my last version! I also think that the process of working through this project helps to demystify React and the kind of components you too can build with it.

All code is public domain using the "Unlicense" license (which is frequently longer than the source code in a file lol!)

Requirements

Here's the requirements for Virtual Window

  • Create a virtual list that can render very large arrays and feel to the user as if there is "nothing special going on"
  • Create a virtual list without needing an array, but by specifying a totalCount and using the rendered component to retrieve the necessary information
  • Size automatically to fit a parent container, no need to specify a fixed height
  • Render items of varying heights
  • Render items that can change height
  • Render an arbitrary set of child React components so that anything can have a "window" placed over it
  • Provide item visibility via an event to enable endless scrolling

Demos of the final solution

Before we get into the meat of building out this solution, let's take a quick look at what we are going to produce in action.

A virtualised array of items with variable height, each item can change height.


export const items = Array.from({ length: 2000 }, (_, i) => ({
  content: i,
  color: randomColor()
}))

export function Route1() {
  const classes = useStyles()

  return (
    <div className="App">
      <div className={classes.virtualBox}>
        <VirtualWindow list={items} item={<DummyItem />} />
      </div>
    </div>
  )
}

Enter fullscreen mode Exit fullscreen mode

A virtual list using a total count.


export function Route3() {
  const classes = useStyles()

  return (
    <div className="App">
      <div className={classes.virtualBox}>
        <VirtualWindow
          totalCount={1500000}
          item={<VirtualItem />}
        />
      </div>
    </div>
  )
}

Enter fullscreen mode Exit fullscreen mode

A virtual window over a set of arbitrary React components.


export function Route2() {
  const classes = useStyles()

  return (
    <div className="App">
      <div className={classes.virtualBox}>
        <VirtualWindow overscan={3}>
          <DummyUser />
          <DummyUser />
          <DummyUser />
          <Buttons />
          <DummyUser />
          <DummyUser />
          <DummyUser />
          <DummyUser />
          <Buttons />
          <DummyUser />
          <Buttons />
          <DummyUser />
          <Buttons />
          <DummyUser />
          <DummyUser />
          <DummyUser />
          <Buttons />
        </VirtualWindow>
      </div>
    </div>
  )
}

Enter fullscreen mode Exit fullscreen mode


Use VirtualWindow

Feel free to just use VirtualWindow by grabbing the code from the GitHub repo or by using:

npm i virtual-window
Enter fullscreen mode Exit fullscreen mode

Then

import { VirtualWindow } from 'virtual-window'
Enter fullscreen mode Exit fullscreen mode

The Project

Let's start off with a brief description of our objective: we are going to make a large scrolling area, the right size to fit all of our content and we are only going to mount the content that would currently be visible significantly reducing the amount of time React takes to render our UI.

Fundamental choices

Using JSX.Elements

It's a common misconception that the following code calls MyComponent():

    return <MyComponent key="someKey" some="prop"/>
Enter fullscreen mode Exit fullscreen mode

This does not call MyComponent() immediately. It creates a virtual DOM node that has a reference to the MyComponent function, the props, key etc. React will call MyComponent() if it thinks it needs to: e.g. the props have changed, it can't find an existing mounted component with the key etc. React will do this when it needs to render the item, because the Virtual DOM Node is the child of another mounted item that is rendering, because it's hooks have changed or because it was the root of a tree of components mounted using something like ReactDom.render().

In our code we will frequently create Virtual DOM Nodes, keep them hanging around and use their props. It's just fine to do this, React isn't magic, "React is just Javascript" and we will use this to our advantage.

Use a normal scrolling div

We want to give the user a standard interface to scroll, a standard <div/> with normal scrollbars. We don't want to do any flaky pass-through of scrolling events or mouse clicks so our rendered items must be children of the item which scrolls (diagrams on this coming up).


Project phase 1: Fixed height Virtual List

We are going to take this in stages so that you can better understand the principles and not be over-faced with the more complicated code associated with variable height items until we have the core understood. So to that end, our first phase of this project will be to build a virtual list of items that all have the same height, then in phase 2 we will adapt it to create a variable height version.

Here's a standard scrolling div in React:

Diagram of list items with a container

Even though some items are off screen they are still being Rendered to the DOM, just they aren't visible.

We've stated that we only want to render visible items so what we need to do is work out which the first visible item is, render that in the right place and then keep going until we have passed outside of the visible window.

The easiest way to reason with the items being rendered is to use relative coordinates to the view on the screen. So for instance the top of the visible window is 0.

With fixed size items we know the total length of the scrolling area in pixels as totalHeight = totalCount * itemSize and if we are scrolled to position top then the first partially or fully visible item is Math.floor(top / itemSize). The amount the item is off the top of the screen is -(top % itemSize).

Diagram of calculating the position of the top element in a list

The structure of the view

Now let's get into how we are going to structure the elements that make up our component.

First, we need a scrolling container at the base, within that we need a <div/> which dictates the height of the scroll bar - so it is going to be itemSize * totalCount pixels tall.

We need another <div/> to contain the virtual items. We don't want this to mess with the height of the scroller - so it will be height: 0 but will also be overflow: visible. In this way the only thing controlling the scrollHeight of the scrolling element is our empty <div/>.

We will position the virtual elements that are being scrolled in absolute coordinates.

Diagram of the structure of the virtual window scroller

This height: 0 div is very important, otherwise when we drew a virtual item with a negative top it would affect the size of the containing element.

We want to reason with the top of the rendered items being 0 because it makes the maths easier, but in truth because the height: 0 <div/> is a child of the scroller, it will also be scrolled - so we will have to finally add back on its offset at the end of our calculations.

Example of the need for offsetting rendered items

The VirtualFixedRepeat Steps

So here are the steps we need to create our fixed virtual repeat.

  1. Measure the available height for our container
  2. Create a scrollable <div/> as our outer wrapper
  3. Create the fixed size empty <div/> that sets the scroll height inside the wrapper
  4. Create the height: 0 <div/> that contains the items shown to the user inside the wrapper
  5. Draw the physical items in the right place based on the scrollTop of the wrapper
  6. When the wrapper is scrolled redraw the items in the new position

The VirtualFixedRepeat Code

So time to get to some coding, let's look at the utilities we need for this first part.

  • Measure the size of something
  • Know when something has scrolled

useObserver/useMeasurement

We will start our coding journey by writing two hooks to help us measuring things, we will need to measure a lot of things for the final solution, but here we just need to measure the available space.

To measure things we can use ResizeObserver which has a polyfill for IE11, if you need to support that stack. ResizeObserver allows us to supply a DOM element and receive an initial notification of its dimensions to a callback, which will also receive a notification when the dimensions change.

To manage the lifetime of the ResizeObserver instances we make, we create a useObserver hook. In this hook we will wrap a ResizeObserver instance in a useEffect hook. As we are doing this we can also simplify the data from the callback

import { useCallback, useEffect, useMemo } from "react"

export function useObserver(measure, deps = []) {
  const _measure = useCallback(measureFirstItem, [measure, ...deps])
  const observer = useMemo(() => new ResizeObserver(_measure), [
    _measure,
    ...deps
  ])
  useEffect(() => {
    return () => {
      observer.disconnect()
    }
  }, [observer])
  return observer

  function measureFirstItem(entries) {
    if (!entries?.length) return
    measure(entries[0])
  }
}
Enter fullscreen mode Exit fullscreen mode

We supply useObserver with a function that will be called back with a measurement and an optional array of additional dependencies, then we use the useMemo and useEffect pattern to immediately create an instance and then free any previously created ones.

Note the closure inside the useEffect(), the "unmount" function we return will be closed over the former value of observer, that's one of those things that becomes natural, but takes a bit of thinking about the first time. useEffect runs when the observer changes, it returns a function which references observer. The value of observer at that moment is baked into the closure.

Now we have an observer, we can write a hook to measure things. This hook needs to return the size of something and a ref to attach to the thing we want measuring.


import { useCallback, useState, useRef } from "react"
import { useObserver } from "./useObserver"

export function useMeasurement() {
  const measure = useCallback(measureItem, [])
  const observer = useObserver(measure, [])
  const currentTarget = useRef(null)
  // a ref is just a function that is called
  // by React when an element is mounted
  // we use this to create an attach method
  // that immediately observes the size
  // of the reference
  const attach = useCallback(
    function attach(target) {
      if (!target) return
      currentTarget.current = target
      observer.observe(target)
    },
    [observer]
  )
  const [size, setSize] = useState({})

  // Return the size, the attach ref and the current
  // element attached to
  return [size, attach, currentTarget.current]

  function measureItem({ contentRect, target }) {
    if (contentRect.height > 0) {
      updateSize(target, contentRect)
    }
  }
  function updateSize(target, rect) {
    setSize({
      width: Math.ceil(rect.width),
      height: Math.ceil(rect.height),
      element: target
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

To allow us to measure what we like, the second element of the array returned is a function we pass to the measured item as a ref={}. A ref is a function called back with the current value of something - so that is what useRef() normally does, returns a function that when called updates the value of someRef.current.

We can now measure things like this:

function MyComponent() {
    const [size, attach] = useMeasurement()
    return <div ref={attach}>
        The height of this div is {size.height ?? "unknown"} pixels
    </div>
}
Enter fullscreen mode Exit fullscreen mode

useScroll hook

For the fixed sized version, we only need to measure the thing that will scroll, so we make a hook that combines all of this together: useScroll

import { useEffect, useRef, useState } from "react"
import { useObserver } from "./useObserver"
import _ from "./scope"

const AVOID_DIVIDE_BY_ZERO = 0.001

export function useScroll(whenScrolled) {
  const observer = useObserver(measure)
  const scrollCallback = useRef()
  scrollCallback.current = whenScrolled

  const [windowHeight, setWindowHeight] = useState(AVOID_DIVIDE_BY_ZERO)
  const scroller = useRef()
  useEffect(configure, [observer])
  return [scroller, windowHeight, scroller.current]

  function configure() {
    if (!scroller.current) return
    let observed = scroller.current
    observer.observe(observed)
    observed.addEventListener("scroll", handleScroll, { passive: true })
    return () => {
      observed.removeEventListener("scroll", handleScroll)
    }

    function handleScroll(event) {
      if (scrollCallback.current) {
        _(event.target)(_ => {
          scrollCallback.current({
            top: Math.floor(_.scrollTop),
            left: Math.floor(_.scrollLeft),
            height: _.scrollHeight,
            width: _.scrollWidth
          })
        })
      }
    }
  }

  function measure({ contentRect: { height } }) {
    setWindowHeight(height || AVOID_DIVIDE_BY_ZERO)
  }
}
Enter fullscreen mode Exit fullscreen mode

The useScroll hook measures the thing you attach it's returned ref to and also adds a scroll listener to it. The listener will callback a supplied function whenever the item is scrolled.

Putting it together

Now we have the parts of a fixed virtual list we need to render the actual component itself. I split this component into four phases:

  1. Configuration - setup the necessary hooks etc
  2. Calculation - work out what we are going to render
  3. Notification - dispatch any events about the items being rendered
  4. Render - return the finally rendered structure

Our VirtualFixedRepeat has the following signature:

export function VirtualFixedRepeat({
  list,
  totalCount = 0,
  className = "",
  itemSize = 36,
  item = <Simple />,
  onVisibleChanged = () => {},
  ...props
})
Enter fullscreen mode Exit fullscreen mode

We have the component to render each list entry in item (with a fallback to a Fragment clone that doesn't care about being passed additional props). We have the list and the total count of items - if we don't supply list, we must supply totalCount. There's an event for the parent to be notified about visible items, and of course the fixed vertical size of an item!

The additional props can include a keyFn that will be passed on down and used to work out a key for elements being rendered for some special cases.

Configuration

Ok so here is the configuration phase of the list:

// Configuration Phase

  const [{ top = 0 }, setScrollInfo] = useState({})

  const [scrollMonitor, windowHeight] = useScroll(setScrollInfo)

  totalCount = list ? list.length : totalCount
Enter fullscreen mode Exit fullscreen mode

We have a state to hold the current scroll position called top and we just pass the setter for that to a useScroll hook that returns the ref to attach in scrollMonitor and the current height of the item it is attached to. We will make the <div/> we return be a flex=1 and height=100% so it will fill its parent.

Finally we update the totalCount from the list if we have one.

Calculation
  // Calculation Phase

  let draw = useMemo(render, [
    top,
    props,
    totalCount,
    list,
    itemSize,
    windowHeight,
    item
  ])

  const totalHeight = itemSize * totalCount
Enter fullscreen mode Exit fullscreen mode

We render the items we want to an array called draw and we work out the height of the empty <div/> based on the information provided.

Clearly the lions share of the work happens in render


  function render() {
    return renderItems({
      windowHeight,
      itemSize,
      totalCount,
      list,
      top,
      item,
      ...props
    })
  }

Enter fullscreen mode Exit fullscreen mode

render is a closure, calling a global function renderItems


function renderItems({
  windowHeight,
  itemSize,
  totalCount,
  list,
  top,
  ...props
}) {
  if (windowHeight < 1) return []

  let draw = []

  for (
    let scan = Math.floor(top / itemSize), start = -(top % itemSize);
    scan < totalCount && start < windowHeight;
    scan++
  ) {
    const item = (
      <RenderItem
        {...props}
        top={start}
        offset={top}
        key={scan}
        index={scan}
        data={list ? list[scan] : undefined}
      />
    )
    start += itemSize

    draw.push(item)
  }
  return draw
}

Enter fullscreen mode Exit fullscreen mode

Ok at last, here it is! We work out the top item and the negative offset as described earlier, then we run through the list adding <RenderItem/> instances for each one. Notice we pass the current offset (as described above) to ensure we are dealing with scrolled lists properly.

Here's RenderItem:

import { useMemo } from "react"
import { getKey } from "./getKey"

export function RenderItem({
  data,
  top,
  offset,
  item,
  keyFn = getKey,
  pass = "item",
  index
}) {
  const style = useMemo(
    () => ({
      top: top + offset,
      position: "absolute",
      width: "100%",
    }),
    [top, offset]
  )

  return (
      <div style={style}>
        <item.type
          key={data ? keyFn(data) || index : index}
          {...{ ...item.props, [pass]: data, index }}
        />
      </div>
    )
  )
}

Enter fullscreen mode Exit fullscreen mode

Ok so if you read the earlier article I wrote, you'll know about the fact that doing <SomeComponent/> returns an object that has the .type and .props necessary to just create a copy. This is what we are doing here.

We create a style (memoised to avoid unnecessary redraws) then we create an instance of the template item we want to draw for each list entry, passing it the current index and any data from the array in a prop called item unless we passed a different name to the VirtualFixedRepeat.

Notification

Back to the main body of VirtualFixedRepeat and we now need to notify the parent of what is being drawn:

  //Notification Phase

  useVisibilityEvents()

Enter fullscreen mode Exit fullscreen mode

We have a local closure hook to send the events:


  function useVisibilityEvents() {
    // Send visibility events
    const firstVisible = draw[0]
    const lastVisible = draw[draw.length - 1]
    useMemo(() => onVisibleChanged(firstVisible, lastVisible), [
      firstVisible,
      lastVisible
    ])
  }
Enter fullscreen mode Exit fullscreen mode

It just gets the first and last element being drawn and uses a useMemo to only call the parent supplied onVisibleChanged when they change.

Rendering

The final step is to render our component structure:

  // Render Phase

  const style = useMemo(() => ({ height: totalHeight }), [totalHeight])

  return (
    <div ref={scrollMonitor} className={`vr-scroll-holder ${className}`}>
      <div style={style}>
        <div className="vr-items">{draw}</div>
      </div>
    </div>
  )
Enter fullscreen mode Exit fullscreen mode
.vr-items {
  height: 0;
  overflow: visible;
}

.vr-scroll-holder {
  height: 100%;
  flex: 1;
  position: relative;
  overflow-y: auto;
}
Enter fullscreen mode Exit fullscreen mode

The whole of VirtualFixedRepeat

export function VirtualFixedRepeat({
  list,
  totalCount = 0,
  className = "",
  itemSize = 36,
  item = <Simple />,
  onVisibleChanged = () => {},
  ...props
}) {
  // Configuration Phase

  const [{ top = 0 }, setScrollInfo] = useState({})

  const [scrollMonitor, windowHeight] = useScroll(setScrollInfo)

  totalCount = list ? list.length : totalCount

  // Calculation Phase

  let draw = useMemo(render, [
    top,
    totalCount,
    list,
    itemSize,
    windowHeight,
    item
  ])

  const totalHeight = itemSize * totalCount

  //Notification Phase

  useVisibilityEvents()

  // Render Phase

  const style = useMemo(() => ({ height: totalHeight }), [totalHeight])

  return (
    <div ref={scrollMonitor} className={`${className} vr-scroll-holder`}>
      <div style={style}>
        <div className="vr-items">{draw}</div>
      </div>
    </div>
  )

  function render() {
    return renderItems({
      windowHeight,
      itemSize,
      totalCount,
      list,
      top,
      item,
      ...props
    })
  }

  function useVisibilityEvents() {
    // Send visibility events
    const firstVisible = draw[0]
    const lastVisible = draw[draw.length - 1]
    useMemo(() => onVisibleChanged(firstVisible, lastVisible), [
      firstVisible,
      lastVisible
    ])
  }
}

function renderItems({
  windowHeight,
  itemSize,
  totalCount,
  list,
  top,
  ...props
}) {
  if (windowHeight < 1) return [[], []]

  let draw = []

  for (
    let scan = Math.floor(top / itemSize), start = -(top % itemSize);
    scan < totalCount && start < windowHeight;
    scan++
  ) {
    const item = (
      <RenderItem
        {...props}
        visible={true}
        top={start}
        offset={top}
        key={scan}
        index={scan}
        data={list ? list[scan] : undefined}
      />
    )
    start += itemSize

    draw.push(item)
  }
  return draw
}

Enter fullscreen mode Exit fullscreen mode

And here it is in action:


Project phase 2: Variable height items

So why is it that variable heights are so complicated? Well imagine we have a virtual list of 1,000,000 items. If we want to work out the what to draw in the list given some value of top, the naive approach is to add up all of the heights until we get to top. Not only is this slow, but we also need to know the heights! To know them we need to render the items. Oh... yeah that's not going to work.

My last attempt at this had a "very clever" height calculator and estimator. I say "very clever" - I might say "too clever" but anyway lets not dwell on that. I had a bit of a "Eureka" moment.

The user is either scrolling smoothly or picking up the scroll thumb and jumping miles. Code for that!

We can easily get an expectedSize by averaging the heights of all of the items that have been drawn. If the user is scrolling big amounts, guess where it should be using that.

When the user is scrolling small amounts (say less than a few pages) use the delta of their scroll to move things that are already there and fill in the blanks.

Now the problem with this approach is that errors will creep in between big and small scrolling - and "Eureka again!"... just fix them when they happen. Which is only at the top and bottom of this list. Just go fix it. If first item is below the top of the window, move the scroll to 0 etc!

A new hope

Ok so now we have a plan for variable heights, we still have more work to do. We can't just render the things directly on the screen because their positions are affected by things "off" the screen. So we need to overscan and render more items.

Rendering variable height items

We also need to calculate the heights of things and we don't want the display moving around, so we need to have two kinds of item. Ones that are rendered visible because we know how high they are, and ones that are rendered invisible because we are measuring them. To avoid any nasties, if we find any item that unknown height then we don't make anything else visible after.

Known and unknown height items

And finally when we can, we want to move things already there with the delta of the scroll:

Moving items using a delta

More helpers

Now we need to measure everything, we need to know how many things we have measured and we need to know the total amount of height we've measured so that we can get an expectedSize. Also things are going to change height and we need to relayout when they do.

useDebouncedRefresh

First lets solve the problem of having a function that causes our component to re-render and debounces it a little as many items may be reporting their heights at the same time.

import { useCallback, useState } from "react"

const debounce = (fn, delay) => {
  let timer = 0
  return (...params) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn(...params), delay)
  }
}

export function useDebouncedRefresh() {
  const [refresh, setRefresh] = useState(0)
  // eslint-disable-next-line react-hooks/exhaustive-deps
  const changed = useCallback(
    debounce(() => setRefresh(i => i + 1)),
    [setRefresh]
  )
  changed.id = refresh
  return changed
}

Enter fullscreen mode Exit fullscreen mode

This uses a simple useState hook to cause a redraw and then returns a debounced function that when called will update the state.

MeasuredItem and MeasurementContext

We need to measure lots of things now, so we have a context to put the results in that has a lookup of height by item index and the totals etc.

import { useContext, useState, createContext } from "react"
import { useMeasurement } from "./useMeasurement"

export const MeasuredContext = createContext({
  sizes: {},
  measuredId: 1,
  total: 0,
  count: 0,
  changed: () => {}
})

const EMPTY = { height: 0, width: 0 }

export function Measured({ children, style, id }) {
  const context = useContext(MeasuredContext)
  const [measureId] = useState(() =>
    id === undefined ? context.measureId++ : id
  )
  const [size, attach] = useMeasurement(measureId, true)
  const existing = context.sizes[measureId] || EMPTY
  if (size.height > 0 && size.height !== existing.height) {
    if (existing === EMPTY) {
      context.count++
    }
    context.total -= existing.height
    context.total += size.height
    context.sizes[measureId] = size
    context.changed()
  }

  return (
    <div key={measureId} style={style} ref={attach}>
      {children}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

We will use a useDebouncedRefresh() in place of the default empty changed method to cause our component to layout again when any heights change. As you can see, useMeasurement is used to track changes to item heights and store them in an easy to access structure we can just query at any time with a time complexity of O(1). We can now use <MeasuredItem> inside our <RenderItem/> component instead of the wrapping <div/> and we can quickly know the sizes of all of the items we are rendering.

return (
    (
      <Measured id={index} style={style}>
        <item.type
          key={data ? keyFn(data) || index : index}
          {...{ ...item.props, [pass]: data, index }}
        />
      </Measured>
    )
  )
Enter fullscreen mode Exit fullscreen mode

Our new variable height VirtualWindow

It's finally time to write <VirtualWindow/> we are going to use the same phases as before:

  1. Configuration - setup the necessary hooks etc
  2. Calculation - work out what we are going to render
  3. Notification - dispatch any events about the items being rendered
  4. Render - return the finally rendered structure

The signature hasn't changed much, we will use "itemSize" as a temporary size until we've measured at least two things. We add the ability to take the children of <VirtualWindow/> as the list of things to render:

export function VirtualWindow({
  children,
  list = children?.length ? children : undefined,
  totalCount = 0,
  itemSize = 36,
  item = <Simple />,
  onVisibleChanged = () => {},
  overscan = 2,
  ...props
})
Enter fullscreen mode Exit fullscreen mode
Configuration
 // Configuration Phase

  const [{ top = 0 }, setScrollInfo] = useState({})
  const previousTop = useRef(0)
  const changed = useDebouncedRefresh()
  const lastRendered = useRef([])

  const [scrollMonitor, windowHeight, scrollingElement] = useScroll(
    setScrollInfo
  )

  const measureContext = useMemo(
    () => ({
      sizes: {},
      changed,
      total: 0,
      count: 0
    }),
    [changed]
  )

  totalCount = list ? list.length : totalCount
Enter fullscreen mode Exit fullscreen mode

We've added to the configuration phase a new object that will be our MeasuredContext value. We have a changed function from useDebouncedRefresh() and we have refs for the previously rendered items and the previous scroll position so we can work out the delta of the scroll.

Calculation
 // Calculation Phase

  let delta = Math.floor(previousTop.current - top)
  previousTop.current = top

  const expectedSize = Math.floor(
    measureContext.count > 2
      ? measureContext.total / measureContext.count
      : itemSize
  )

  let [draw, visible] = useMemo(render, [
    top,
    delta,
    props,
    expectedSize,
    totalCount,
    list,
    measureContext,
    windowHeight,
    item,
    overscan
  ])

  const totalHeight = Math.floor(
    (totalCount - visible.length) * expectedSize +
      visible.reduce((c, a) => c + a.props.height, 0)
  )

  lastRendered.current = visible
  // Fixup pesky errors at the end of the window
  const last = visible[visible.length - 1]
  if (last && +last.key === totalCount - 1 && totalHeight > windowHeight) {
    if (last.props.top + last.props.height < windowHeight) {
      delta = Math.floor(windowHeight - (last.props.top + last.props.height))
      ;[draw, visible] = render()
      lastRendered.current = visible
    }
  }
  // Fix up pesky errors at the start of the window
  if (visible.length) {
    const first = visible[0]
    if (first.key === 0 && first.props.top > 0) {
      scrollingElement.scrollTop = 0
    }
  }

Enter fullscreen mode Exit fullscreen mode

Here we work out the delta of the scroll, the estimated size of an item from our measure context and render the items.

We now return two arrays from our render method. The items to draw and the items which are visible. The draw array will contain invisible items that are being measured, and this will be what we render at the end of the function, but we want to know what we drew visible too.

We cache the visible items for the next drawing cycle and then we fix up those errors I mentioned. In the case of the end of the window - we work out what we got wrong and just call render again. At the top of the window we can just fix the scrollTop of the scroller.

Note how we now work out the height of the scroller - it's the height of the visible items + the expected size of everything else.

render

renderItems is now split into two things, either render from the expectedSize or move already visible things:

  if (
    !rendered.length ||
    top < expectedSize ||
    Math.abs(delta) > windowHeight * 5
  ) {
    return layoutAll()
  } else {
    return layoutAgain()
  }

Enter fullscreen mode Exit fullscreen mode

We layout all the items in a few cases: the first time, massive scroll, we are at the top of the list etc. Otherwise we try to move the items we already have - this visible items cached from last time, passed in as rendered.

  function layoutAll() {
    const topItem = Math.max(0, Math.floor(top / expectedSize))
    return layout(topItem, -(top % expectedSize))
  }

  function layoutAgain() {
    let draw = []
    let renderedVisible = []
    let firstVisible = rendered.find(f => f.props.top + delta >= 0)
    if (!firstVisible) return layoutAll()
    let topOfFirstVisible = firstVisible.props.top + delta

    if (topOfFirstVisible > 0) {
      // The first item is not at the top of the screen,
      // so we need to scan backwards to find items to fill the space
      ;[draw, renderedVisible] = layout(
        +firstVisible.key - 1,
        topOfFirstVisible,
        -1
      )
    }
    const [existingDraw, exisitingVisible] = layout(
      +firstVisible.key,
      topOfFirstVisible
    )
    return [draw.concat(existingDraw), renderedVisible.concat(exisitingVisible)]
  }
Enter fullscreen mode Exit fullscreen mode

The clever stuff is in layoutAgain. We find the first visible item that after scrolling by delta would be fully on screen. We take this as the middle and then layout backwards and forwards from it. So this is middle-out for all of you Silicon Valley fans :)

The layout function is similar to the fixed one we saw earlier but has conditions suitable for going in both directions and adds the principle of "visibility" based on whether we know the height of an item (per the diagram above). It also maintains two arrays, the draw items and the visible items.

function layout(scan, start, direction = 1) {
    let draw = []
    let renderedVisible = []

    let adding = true

    for (
      ;
      scan >= 0 &&
      start > -windowHeight * overscan &&
      scan < totalCount &&
      start < windowHeight * (1 + overscan);
      scan += direction
    ) {
      let height = sizes[scan]?.height
      if (height === undefined) {
        // Stop drawing visible items as soon as anything
        // has an unknown height
        adding = false
      }
      if (direction < 0) {
        start += (height || expectedSize) * direction
      }
      const item = (
        <RenderItem
          {...props}
          visible={adding}
          height={height}
          top={start}
          offset={top}
          key={scan}
          index={scan}
          data={list ? list[scan] : undefined}
        />
      )
      if (direction > 0) {
        start += (height || expectedSize) * direction
      }
      if (adding) {
        if (direction > 0) {
          renderedVisible.push(item)
        } else {
          // Keep the lists in the correct order by
          // unshifting as we move backwards
          renderedVisible.unshift(item)
        }
      }
      draw.push(item)
    }
    return [draw, renderedVisible]
  }
Enter fullscreen mode Exit fullscreen mode

Notification phase

The notification phase has to do a little more work to find the items that are in the actual visible range, but otherwise is pretty similar:


  function useVisibilityEvents() {
    // Send visibility events
    let firstVisible
    let lastVisible
    for (let item of visible) {
      if (
        item.props.top + item.props.height > 0 &&
        item.props.top < windowHeight
      ) {
        firstVisible = firstVisible || item
        lastVisible = item
      }
    }
    useMemo(() => onVisibleChanged(firstVisible, lastVisible), [
      firstVisible,
      lastVisible
    ])
  }
Enter fullscreen mode Exit fullscreen mode
Render phase

The render phase only needs to add our MeasuredContext so the items can report in their sizes:

  // Render Phase

  const style = useMemo(() => ({ height: totalHeight }), [totalHeight])

  return (
    <MeasuredContext.Provider value={measureContext}>
      <div ref={scrollMonitor} className="vr-scroll-holder">
        <div style={style}>
          <div className="vr-items">{draw}</div>
        </div>
      </div>
    </MeasuredContext.Provider>
  )
Enter fullscreen mode Exit fullscreen mode
The whole kit and caboodle

Complete VirtualWindow function

import { useMemo, useState, useRef } from "react"
import { MeasuredContext } from "./Measured"
import { useDebouncedRefresh } from "./useDebouncedRefresh"
import { useScroll } from "./useScroll"
import { RenderItem } from "./RenderItem"
import { Simple } from "./Simple"
import "./virtual-repeat.css"

export function VirtualWindow({
  children,
  list = children?.length ? children : undefined,
  totalCount = 0,
  itemSize = 36,
  item = <Simple />,
  onVisibleChanged = () => {},
  overscan = 2,
  ...props
}) {
  // Configuration Phase

  const [{ top = 0 }, setScrollInfo] = useState({})
  const previousTop = useRef(0)
  const changed = useDebouncedRefresh()
  const lastRendered = useRef([])

  const [scrollMonitor, windowHeight, scrollingElement] = useScroll(
    setScrollInfo
  )

  const measureContext = useMemo(
    () => ({
      sizes: {},
      changed,
      total: 0,
      count: 0
    }),
    [changed]
  )

  totalCount = list ? list.length : totalCount

  // Calculation Phase

  let delta = Math.floor(previousTop.current - top)
  previousTop.current = top

  const expectedSize = Math.floor(
    measureContext.count > 2
      ? measureContext.total / measureContext.count
      : itemSize
  )

  let [draw, visible] = useMemo(render, [
    top,
    delta,
    props,
    expectedSize,
    totalCount,
    list,
    measureContext,
    windowHeight,
    item,
    overscan
  ])

  const totalHeight = Math.floor(
    (totalCount - visible.length) * expectedSize +
      visible.reduce((c, a) => c + a.props.height, 0)
  )

  lastRendered.current = visible
  const last = visible[visible.length - 1]
  if (last && +last.key === totalCount - 1 && totalHeight > windowHeight) {
    if (last.props.top + last.props.height < windowHeight) {
      delta = Math.floor(windowHeight - (last.props.top + last.props.height))
      ;[draw, visible] = render()
      lastRendered.current = visible
    }
  }

  if (visible.length) {
    const first = visible[0]
    if (first.key === 0 && first.props.top > 0) {
      scrollingElement.scrollTop = 0
    }
  }

  //Notification Phase

  useVisibilityEvents()

  // Render Phase

  const style = useMemo(() => ({ height: totalHeight }), [totalHeight])

  return (
    <MeasuredContext.Provider value={measureContext}>
      <div ref={scrollMonitor} className="vr-scroll-holder">
        <div style={style}>
          <div className="vr-items">{draw}</div>
        </div>
      </div>
    </MeasuredContext.Provider>
  )

  function render() {
    return renderItems({
      windowHeight,
      expectedSize,
      rendered: lastRendered.current,
      totalCount,
      delta,
      list,
      measureContext,
      top,
      item,
      overscan,
      ...props
    })
  }

  function useVisibilityEvents() {
    // Send visibility events
    let firstVisible
    let lastVisible
    for (let item of visible) {
      if (
        item.props.top + item.props.height > 0 &&
        item.props.top < windowHeight
      ) {
        firstVisible = firstVisible || item
        lastVisible = item
      }
    }
    useMemo(() => onVisibleChanged(firstVisible, lastVisible), [
      firstVisible,
      lastVisible
    ])
  }
}

function renderItems({
  windowHeight,
  expectedSize,
  rendered,
  totalCount,
  delta,
  list,
  overscan = 2,
  measureContext,
  top,
  ...props
}) {
  if (windowHeight < 1) return [[], []]
  const { sizes } = measureContext
  if (
    !rendered.length ||
    top < expectedSize ||
    Math.abs(delta) > windowHeight * 5
  ) {
    return layoutAll()
  } else {
    return layoutAgain()
  }

  function layoutAll() {
    const topItem = Math.max(0, Math.floor(top / expectedSize))
    return layout(topItem, -(top % expectedSize))
  }

  function layoutAgain() {
    let draw = []
    let renderedVisible = []
    let firstVisible = rendered.find(f => f.props.top + delta >= 0)
    if (!firstVisible) return layoutAll()
    let topOfFirstVisible = firstVisible.props.top + delta

    if (topOfFirstVisible > 0) {
      // The first item is not at the top of the screen,
      // so we need to scan backwards to find items to fill the space
      ;[draw, renderedVisible] = layout(
        +firstVisible.key - 1,
        topOfFirstVisible,
        -1
      )
    }
    const [existingDraw, exisitingVisible] = layout(
      +firstVisible.key,
      topOfFirstVisible
    )
    return [draw.concat(existingDraw), renderedVisible.concat(exisitingVisible)]
  }

  function layout(scan, start, direction = 1) {
    let draw = []
    let renderedVisible = []

    let adding = true

    for (
      ;
      scan >= 0 &&
      start > -windowHeight * overscan &&
      scan < totalCount &&
      start < windowHeight * (1 + overscan);
      scan += direction
    ) {
      let height = sizes[scan]?.height
      if (height === undefined) {
        adding = false
      }
      if (direction < 0) {
        start += (height || expectedSize) * direction
      }
      const item = (
        <RenderItem
          {...props}
          visible={adding}
          height={height}
          top={start}
          offset={top}
          key={scan}
          index={scan}
          data={list ? list[scan] : undefined}
        />
      )
      if (direction > 0) {
        start += (height || expectedSize) * direction
      }
      if (adding) {
        if (direction > 0) {
          renderedVisible.push(item)
        } else {
          renderedVisible.unshift(item)
        }
      }
      draw.push(item)
    }
    return [draw, renderedVisible]
  }
}

Enter fullscreen mode Exit fullscreen mode

Conclusion

There is a lot to digest in this article for sure, but hopefully even the individual hooks could prove useful or inspirational for your own code. The code for this project is available on GitHub:

GitHub logo miketalbot / virtual-window

A React component that can virtualise lists and any set of children.

Also available on CodeSandbox

Or just use it in your own project:

npm i virtual-window
Enter fullscreen mode Exit fullscreen mode
import { VirtualWindow } from 'virtual-window'
Enter fullscreen mode Exit fullscreen mode

Areas for improvement

  • Bigger scrolling areas

At present the height of the scroller is limited by the browser's maximum height of a scroll area. This could be mitigated by multiplying the scroll position by a factor, the scroll wheel would not be pixel perfect in this situation and it needs more investigation.

Discussion (0)