Not long ago, I was part of a development team that was creating a SAAS application that had to render a lot of data (The point of that particular module was to essentially mimic a social media). As each of the items was pretty heavy React components themselves, needless to say, we had to use render optimization techniques to improve the UI performance and Virtualization is one of the most used techniques throughout the industry.
Today, I’ll explain the component I created back then in order to understand the nitty-gritty of the libraries used in most projects. This is a pretty advanced demo since I went over the usual implementation and added some enhancements of my own. and I’ll guide you through all the steps so you’ll get a solid understanding of the trickery behind this very performant solution. Rendering countless items with the breeze.
I know what you're thinking !! why re-invent (react-virtualized) the wheel, if there is already a battle-tested solution why even bother to create your own from scratch? well, the thing is most people don't even know how things work behind the scenes and that's dangerous!! for your code-base and for your knowledge as well. Not only will you be able to customize every single aspect of the final component, but you’ll also understand the existing limitations and what you could do to improve them, which will help you to become the best DEV you want to be.
Before we get started there is some stuff you need to know first.
Typescript/Javascript (I prefer the former)
React (You can definitely choose any other UI client, for this demo I'm going for React)
Basics of how the browser works
Virtualization
solely in the context of Ui --> Virtualization means maintaining/holding some data that is not entirely present in the rendered canvas (In the case of the web that is DOM), in fact the entier initial idea behind react's core architecture was based on Virtual dom which just iterates the basic idea behind virtualization. The concept of Virtualized list isn't new to the world in fact Native platforms like Android/IOS and desktop apps have been doing this out of the box for quite some time, and though there is no Browser-first API for this, the technique is pretty well-known --> when you have an abnormally large list of components to be rendered instead of mounting all the elements to the DOM (which will just create a truck-load of resource-overhead ) we can just render out the few items that are expected to be in the view-port of the respective container at that point of time .. That's it, that's the big secret , No! I'm not kidding its that simple, and once you know how exactly it'll be apparent to you.
Component structure
Let's define our component schema so that we can establish what we are trying to achieve
export interface WindowProps {
rowHeight: number;
children: Array<JSX.Element>;
gap?: number;
isVirtualizationEnabled?: boolean;
}
Here as enhancement, we would not be passing container width as a prop, as an intelligent component it should be able to deduce the container width on its own (One of the reasons why I wanted to build my own)
and as react children we will only accept a list of js elements only, the typescript restriction isn't that specific but you can go a few steps deeper and only accept a particular list having a pre-defined prop structure (That's a topic for another time). needless to say, all the children need to be homogeneous components having similar structure
the gap indicates the gap visible between two elements, we need to preset the rowHeight since our component needs to have a fixed row height ( We can however extract this from children but that's just unnecessary because making it dynamic would just create calculation overhead which is a different problem altogether), isVirtualizationEnabled is just an additional prop to demonstrate the performance benefit
Implementation details
const [containerRef, { height: containerHeight }] = useElementSize<
HTMLUListElement
>();
const [scrollPosition, setScrollPosition] = React.useState(0);
for sake of utility I'm using a custom hook useElementSize
to keep track of the container of our Window component
( You can create one yourself, go have a try at it )
and another state scrollPosition to maintain the top scroll height of the container while scrolling.
const onScroll = React.useMemo(
() =>
throttle(
function (e: any) {
setScrollPosition(e.target.scrollTop);
},
50,
{ leading: false }
),
[]
);
this is the callback that will maintain our scrolled position in the container , and here I have used the throttle from lodash to optimize the scroll events further since the onScroll events are fired multiple times due to how the browser handles DOM events ( A very good use-case of why we use Throttling ), I'm updating the scroll position after every 50ms.
Now let's talk about the big fish ( How to actually render the children )
// get the children to be renderd
const visibleChildren = React.useMemo(() => {
if (!isVirtualizationEnabled)
return children.map((child, index) =>
React.cloneElement(child, {
style: {
position: "absolute",
top: index * rowHeight + index * gap,
height: rowHeight,
left: 0,
right: 0,
lineHeight: `${rowHeight}px`
}
})
);
const startIndex = Math.max(
Math.floor(scrollPosition / rowHeight) - bufferedItems,
0
);
const endIndex = Math.min(
Math.ceil((scrollPosition + containerHeight) / rowHeight - 1) +
bufferedItems,
children.length - 1
);
return children.slice(startIndex, endIndex + 1).map((child, index) =>
React.cloneElement(child, {
style: {
position: "absolute",
top: (startIndex + index) * rowHeight + index * gap,
height: rowHeight,
left: 0,
right: 0,
lineHeight: `${rowHeight}px`
}
})
);
}, [
children,
containerHeight,
rowHeight,
scrollPosition,
gap,
isVirtualizationEnabled
]);
Here we need to calculate the start index and ending index from the slice of children that we want to render and clone these from props with mentioned properties each child will be rendered with an offset from the top of the container which we can easily calculate with the scroll position and row height and the index of a child, observe that we have kept the children position absolute, it's because normal display : flex in the container won't work because how flex boxes work in the DOM, it'll fire additional scroll events after the initial render which in turn will create an infinite render-loop, that is why we need to fix the position of each child in the container with gap and offset, and I have used useMemo just to control the render cycle.
( I have used the cloneElement method from React so that the rendering of the actual elements is de-coupled from our Window component, there are multiple ways to handle this, for example, you could use the Render-props pattern to solve this as well )
return (
<ul
onScroll={onScroll}
style={{
overflowY: "scroll",
position: "relative"
}}
ref={containerRef}
className="container"
>
{visibleChildren}
</ul>
);
don't forget to make the container position : relative otherwise it'll just be a mess out there
Performance metrics
For observing the performance gain I have used react-fps, which will monitor the refresh rate of the screen, and added a toggle to enable/disable the virtualization in the component
Hope this helps you clear out the details in-between. and feel free to comment possible improvements that you can think of which will make this more seamless and adaptable to more scenarios.
Here is the sandbox link for the code
https://codesandbox.io/embed/practical-haze-bxfqe9?fontsize=14&hidenavigation=1&theme=dark
And the Github link
https://github.com/Akashdeep-Patra/React-virtualization
Feel free to follow me on other platforms as well
Top comments (3)
Good explanation! Love that you don’t just explain the tools out there but the actual implementation of it 🤩
Thank you so much.
I have a question When I search for an Item in this large list suppose the item doesn't exist in the viewport, then what will happen?
So here comes the pitfalls of virtualisation , since the node isn't there in the dom yet , it won't be accessible. There are workarounds to this problem but you will loose SEO for sure