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
and import it
import { VirtualWindow } from 'virtual-window'
And use it:
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
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.
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>
)
}
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>
)
}
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>
)
}
Use VirtualWindow
Feel free to just use VirtualWindow by grabbing the code from the GitHub repo or by using:
npm i virtual-window
Then
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()
:
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:
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)
.
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.
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.
The VirtualFixedRepeat Steps
So here are the steps we need to create our fixed virtual repeat.
- Measure the available height for our container
- Create a scrollable
<div/>
as our outer wrapper - Create the fixed size empty
<div/>
that sets the scroll height inside the wrapper - Create the
height: 0
<div/>
that contains the items shown to the user inside the wrapper - Draw the physical items in the right place based on the
scrollTop
of the wrapper - 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])
}
}
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 ofobserver
, 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 referencesobserver
. The value ofobserver
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
})
}
}
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>
}
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)
}
}
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:
- Configuration - setup the necessary hooks etc
- Calculation - work out what we are going to render
- Notification - dispatch any events about the items being rendered
- 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
})
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
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
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
})
}
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
}
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>
)
)
}
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()
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
])
}
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>
)
.vr-items {
height: 0;
overflow: visible;
}
.vr-scroll-holder {
height: 100%;
flex: 1;
position: relative;
overflow-y: auto;
}
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
}
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.
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.
And finally when we can, we want to move things already there with the delta of the scroll:
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
}
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>
)
}
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>
)
)
Our new variable height VirtualWindow
It's finally time to write <VirtualWindow/>
we are going to use the same phases as before:
- Configuration - setup the necessary hooks etc
- Calculation - work out what we are going to render
- Notification - dispatch any events about the items being rendered
- 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
})
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
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
}
}
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()
}
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)]
}
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]
}
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
])
}
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>
)
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]
}
}
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:
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
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.
Top comments (3)
Thanks for taking the time to write such a detailed and well-illustrated article!
Too complicated and unreadable... I spent a bunch of hours to understand this code. It works fine, but it also should be easy to understand and maintain.
Thanks for your kind words :)
I find it pretty easy to maintain of course, my apologies that this article didn't work for you.