React Hooks: Concept of Virtualisation!

Pratik Sakhare
Walmart Global Tech Blog
6 min readJan 4, 2022

If you are a front end developer you must have surely faced the issue while rendering a huge list of menu. It just takes too much time to render and update. There are quite a few solutions that we can implement to solve such issue like infinite scrolling and pagination. But today, we will look into the concept of virtualisation.

What is the concept of virtualisation?
In simple terms, it is a technique wherein we render only the small subset of items / rows at any given time, which will be actually visible to the user in the window. Once the user starts scrolling,we render the next set of items and remove the items which went out of view.

React Virtualisation

Now that we have got the idea of what we are trying to achieve, lets deep dive in on how we can create some simple hooks on this concept.
But before we begin with React Virtualisation, let us see how we can observe the rendered items and keep track of their visibility in the window using IntersectionObserver.

What is IntersectionObserver?
The IntersectionObserver API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document’s viewport.

In short, we have an observer which will observe the target with respect to a certain parent element and whenever certain threshold is matched it will call a callback method.

Implementation of use IntersectionObserver hook

import { useEffect, useRef, useState, useCallback } from "react";

const useIntersectionObserver = (elementRef, observerOptions, callback) => {

const [intersectionState, setIntersectionState] = useState({});
const observerCallback = useRef();
observerCallback.current = callback;

const overallCallback = useCallback(([newIntersectionState]) => {
setIntersectionState(newIntersectionState);
if(observerCallback.current) observerCallback.current(newIntersectionState);
}, [setIntersectionState]);

useEffect(() => {
const element = elementRef && elementRef.current;
const hasSupport = !!window.IntersectionObserver;

if (!element || !hasSupport) return;

const {
root = null,
rootMargin = "0%",
threshold = 0,
...restOptions
} = observerOptions;

const observer = new IntersectionObserver(
overallCallback,
{
root,
rootMargin,
threshold,
...restOptions
}
);

observer.observe(element);

return () => observer.disconnect();
}, [elementRef, observerOptions, overallCallback])

return {
intersectionState
}
};

export default useIntersectionObserver;

Now lets begin implementing our useVirtualisation hook

Implementation of useVirtualisation hook

import { useLayoutEffect, useState, useMemo, useCallback, useRef } from "react";
import { useIntersectionObserver } from "./useIntersectionObserver";
import { useSyncStateRef } from "./useSyncStateRef";

const INITIAL_VIRTUAL_STATE = {
virtualBucketStart: 0,
virtualBucketMiddle: 0,
virtualBucketEnd: 1,
calculatedStartIndex: 0,
calculatedEndIndex: 1
};

export const useVirtualisation = ({ childrensCount = 0, parentRef }) => {
const [syncParentRef] = useSyncStateRef({ ref: parentRef });
const [virtualState, setVirtualState] = useState({ ...INITIAL_VIRTUAL_STATE });
const middleChildRef = useRef();

const boundVirtualIndex = useCallback(
(nextStepIndex) => {
return Math.min(Math.max(nextStepIndex, 0), childrensCount);
},
[childrensCount]
);

const getExtraPaddedIndexes = useCallback(
(startIndex, endIndex) => {
const boundedStartIndex = boundVirtualIndex(startIndex);
const boundedEndIndex = Math.max(boundedStartIndex, boundVirtualIndex(endIndex));
const totalRootWidthInElements = boundedEndIndex - boundedStartIndex;
const nextStartIndex = boundVirtualIndex(boundedStartIndex - totalRootWidthInElements);
const nextEndIndex = boundVirtualIndex(boundedEndIndex + totalRootWidthInElements);
const nextMiddleIndex = Math.ceil((boundedStartIndex + boundedEndIndex) / 2);

return {
virtualBucketStart: nextStartIndex,
virtualBucketEnd: nextEndIndex,
virtualBucketMiddle: nextMiddleIndex,
calculatedStartIndex: startIndex,
calculatedEndIndex: endIndex
};
},
[boundVirtualIndex]
);

const safelyGetVirtualisedState = useCallback(
(startIndex, endIndex) => {
return getExtraPaddedIndexes(startIndex, endIndex);
},
[getExtraPaddedIndexes]
);

const hasStateChangedForCallback = (asyncVirtualState, syncVirtualState) => {
const {
virtualBucketStart: asyncVirtualBucketStart,
virtualBucketMiddle: asyncVirtualBucketMiddle,
virtualBucketEnd: asyncVirtualBucketEnd,
childRef: asyncChildRef
} = asyncVirtualState;
const {
virtualBucketStart: syncVirtualBucketStart,
virtualBucketMiddle: syncVirtualBucketMiddle,
virtualBucketEnd: syncVirtualBucketEnd,
childRef: syncChildRef
} = syncVirtualState;
return !(
asyncVirtualBucketStart === syncVirtualBucketStart &&
asyncVirtualBucketMiddle === syncVirtualBucketMiddle &&
asyncVirtualBucketEnd === syncVirtualBucketEnd &&
asyncChildRef === syncChildRef
);
};

const getBodyRemainingTopHeight = () => {
const element = middleChildRef?.current;
if (!element) return 50;
const { virtualBucketStart } = virtualState;
return virtualBucketStart * (element.clientHeight || 50);
};

const getBodyRemainingBottomHeight = () => {
const element = middleChildRef?.current;
if (!element) return 50;
const { virtualBucketEnd } = virtualState;
return (childrensCount - virtualBucketEnd) * (element.clientHeight || 50);
};

const shouldUpdate = (virtualBucketStart, virtualBucketEnd, startIndex, endIndex) => {
return startIndex !== virtualBucketStart || endIndex !== virtualBucketEnd;
};

const getRoughClientHeightEstimate = (intersectionState) => {
const boundingClientRect = intersectionState?.boundingClientRect;
return boundingClientRect?.height || 50;
};

const getSlideEstimateForScroll = (intersectionState) => {
const rootBounds = intersectionState?.rootBounds;
const boundingClientRect = intersectionState?.boundingClientRect;
if (!rootBounds || !boundingClientRect) return [0, 1];
const height = getRoughClientHeightEstimate(intersectionState);
const { bottom: rootBottom, top: rootTop } = rootBounds;
const { bottom: clientBottom, top: clientTop } = boundingClientRect;
const topElementsDifference = Math.ceil(Math.abs(rootTop - clientTop) / height);
const bottomElementsDifference = Math.ceil(Math.abs(rootBottom - clientBottom) / height);
const startIndexSteps = rootTop <= clientTop
? topElementsDifference + 1
: -(topElementsDifference - 1);
const endIndexSteps =
clientBottom <= rootBottom ? bottomElementsDifference + 1 : -(bottomElementsDifference - 1);
return [-startIndexSteps, endIndexSteps];
};

const getNextScrollStep = (intersectionState, targetRefIndex) => {
const [nextStartIndex, nextEndIndex] = getSlideEstimateForScroll(intersectionState);
return [
boundVirtualIndex(targetRefIndex + nextStartIndex),
boundVirtualIndex(targetRefIndex + nextEndIndex)
];
};

const generateSetScrollState = (targetRef, targetRefIndex) => {
const asyncVirtualState = { ...virtualState, childRef: targetRef?.current };
const setScrollState = (newIntersectionState) => {
const [intersectionState] = newIntersectionState;
setVirtualState((syncVirtualState) => {
const { calculatedStartIndex, calculatedEndIndex } = syncVirtualState;
const syncChildRef = intersectionState?.target;
if (
hasStateChangedForCallback(
asyncVirtualState,
{ ...syncVirtualState, childRef: syncChildRef }
)
) return syncVirtualState;
const [startIndex, endIndex] = getNextScrollStep(intersectionState, targetRefIndex);
return shouldUpdate(calculatedStartIndex, calculatedEndIndex, startIndex, endIndex)
? safelyGetVirtualisedState(startIndex, endIndex)
: syncVirtualState;
});
};

return setScrollState;
};

const observerOptions = useMemo(() => {
const rootRef = syncParentRef?.current;
return {
threshold: 1,
rootMargin: "10px",
root: rootRef
};
}, [syncParentRef]);

useIntersectionObserver({
elementRef: middleChildRef,
observerOptions,
callback: generateSetScrollState(middleChildRef, virtualState.virtualBucketMiddle)
});

useLayoutEffect(() => {
setVirtualState((oldVirtualState) => {
const { calculatedStartIndex, calculatedEndIndex } = {
...INITIAL_VIRTUAL_STATE,
...oldVirtualState
};
return safelyGetVirtualisedState(calculatedStartIndex, calculatedEndIndex);
});
}, [parentRef, safelyGetVirtualisedState]);

return {
virtualState,
middleChildRef,
topHeight: getBodyRemainingTopHeight(),
bottomHeight: getBodyRemainingBottomHeight(),
element: { type: middleChildRef?.current?.localName }
};
};

Helper component to use useVirtualistion hook

import { useVirtualisation } from "../utility";
import React, { Fragment } from "react";


export const Virtualisation = ({
childrensCount = 0,
completeRowsProp,
childRenderer,
parentRef,
}) => {
const { topHeight, bottomHeight, element, middleChildRef, virtualState } = useVirtualisation({
childrensCount,
parentRef
});
const { virtualBucketStart, virtualBucketEnd, virtualBucketMiddle } = virtualState;
const renderPaddingElement = (elementHeight) => {
const sampleElement = element;
return !!elementHeight && element?.type && <sampleElement.type style={{ height: elementHeight }} />;
};

const setRef = (node) => {
if (node) middleChildRef.current = node;
return node;
};

const renderVirtualisedChildren = () => {
return (
Array.isArray(completeRowsProp) &&
completeRowsProp
.slice(virtualBucketStart, virtualBucketEnd)
.map((row, index) =>
childRenderer(row, index + virtualBucketStart === virtualBucketMiddle && ((node) => setRef(node)))
)
);
};

return (
<Fragment>
{renderPaddingElement(topHeight)}
{renderVirtualisedChildren()}
{renderPaddingElement(bottomHeight)}
</Fragment>
);
};

What exactly is going on?
Let’s go through the code one by one.

  1. First we attach a ref to parent element and the middle element (initially it is the first element) which is being rendered in the list.
  2. Then, using IntersectionObserver we calculate the actual visible area and then the first element and last element that should be rendered.
  3. Now using this information we can get the next middle element and start observing this element. As soon as this element moves out of the range of window we calculate the next set of elements that should be rendered.

And the final END result!
A couple of things to observe:

  1. Only those elements are rendered that are visible to the user.
  2. As the user starts scrolling we add next elements in line. Also the elements which were visible before, but went out of view are removed.
Demo: React Virtualisation

References & credits:

--

--