React Virtual Window - virtualise anything for a performance boost!
Tags: javascript, react, tutorial, webdev
Published: 2021-08-13 08:58:40

## 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

```bash
npm i virtual-window
```
and import it

```jsx
import { VirtualWindow } from 'virtual-window'
```

And use it:

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

Or on lists by supplying an item to render

```jsx
function MyOtherComponent({list}) {
   return <VirtualWindow pass="data" list={list} item={<SomeComponent/>}/>
}
```

## 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.

```jsx

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>
  )
}

```

{% codesandbox nlm7m initialpath=/route1 view=preview %}

A virtual list using a total count.

```jsx

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

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

```

{% codesandbox nlm7m initialpath=/route3 view=preview %}

A virtual window over a set of arbitrary React components.

```jsx

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>
  )
}

```

{% codesandbox nlm7m initialpath=/route2 view=preview %}

-----

## Use VirtualWindow

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

```bash
npm i virtual-window
```
Then

```js
import { VirtualWindow } from 'virtual-window'
```



-----

## 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()`:

```js
    return <MyComponent key="someKey" some="prop"/>
```
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l41wfrt212udbluh4hie.jpg)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8xsvvhi9hkzcal2g28nt.jpg)

#### 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nrem94q43ys2w610j5ve.jpg)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/aex7z2b7jgcb5gyc4ihl.jpg)

#### The VirtualFixedRepeat Steps

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

1. Measure the available height for our container
1. Create a scrollable `<div/>` as our outer wrapper
1. Create the fixed size empty `<div/>` that sets the scroll height inside the wrapper
1. Create the `height: 0` `<div/>` that contains the items shown to the user inside the wrapper
1. Draw the physical items in the right place based on the `scrollTop` of the wrapper
1. 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

```js
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])
  }
}
```

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.

```js

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
    })
  }
}
```

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:

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

#### 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`

```jsx
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)
  }
}
```

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
1. Calculation - work out what we are going to render
1. Notification - dispatch any events about the items being rendered
1. Render - return the finally rendered structure

Our `VirtualFixedRepeat` has the following signature:

```jsx
export function VirtualFixedRepeat({
  list,
  totalCount = 0,
  className = "",
  itemSize = 36,
  item = <Simple />,
  onVisibleChanged = () => {},
  ...props
})
```
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:

```jsx
// Configuration Phase

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

  const [scrollMonitor, windowHeight] = useScroll(setScrollInfo)

  totalCount = list ? list.length : totalCount
```
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

```jsx
  // Calculation Phase

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

  const totalHeight = itemSize * totalCount
```

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`

```jsx

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

```

render is a closure, calling a global function `renderItems`

```jsx

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
}

```

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`:

```jsx
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>
    )
  )
}

```

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:

```jsx
  //Notification Phase

  useVisibilityEvents()

```

We have a local closure hook to send the events:

```jsx

  function useVisibilityEvents() {
    // Send visibility events
    const firstVisible = draw[0]
    const lastVisible = draw[draw.length - 1]
    useMemo(() => onVisibleChanged(firstVisible, lastVisible), [
      firstVisible,
      lastVisible
    ])
  }
```
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:

```jsx
  // 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>
  )
```

```css
.vr-items {
  height: 0;
  overflow: visible;
}

.vr-scroll-holder {
  height: 100%;
  flex: 1;
  position: relative;
  overflow-y: auto;
}
```

{% collapsible The whole of VirtualFixedRepeat %}

```jsx

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
}

```
{% endcollapsible %}

And here it is in action:

{% codesandbox nlm7m initialpath=/route4 %}

-----

### 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/40xe4c13gb26m6icg93g.jpg)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bzjvidahb1ajp3707u7n.jpg)

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

![Moving items using a delta](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ilr78a0xf6j64ky1xulg.jpg)

#### 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.

```jsx
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
}

```
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. 

```jsx
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>
  )
}
```

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.

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

#### 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
1. Calculation - work out what we are going to render
1. Notification - dispatch any events about the items being rendered
1. 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:

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

##### Configuration

```jsx
 // 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
```
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

```jsx
 // 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
    }
  }

```

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:

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

```
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`.

```jsx
  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)]
  }
```

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.

```jsx
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]
  }
```

#### 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:

```jsx

  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
    ])
  }
```
##### Render phase

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

```jsx
  // 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>
  )
```

##### The whole kit and caboodle
{% collapsible Complete VirtualWindow function %}

```jsx

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]
  }
}

```
{% endcollapsible %}

## 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 miketalbot/virtual-window no-readme %}

Also available on CodeSandbox

{% codesandbox nlm7m initialpath=/route3 view=editor module=/src/lib/VirtualWindow.js %}

Or just use it in your own project:

```bash
npm i virtual-window
```
```js
import { VirtualWindow } from 'virtual-window'
```

### 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.

{% codesandbox n4dpo initialpath=/68JlsjJ2ugh59ju9kuISj/embed %}