note
The article below details a simplified version of the virtualization implementation used by
@resembli/react-virtualized-window
. If you found
it useful, consider giving our main repo a star: Resembli MonoRepo.
What is @resembli/react-virtualized-window
โ
The react-virtualized-window
is a React component that creates virtualized DOM views. A virtualized DOM view
only renders DOM elements that are visible to the user within a container (with a little buffer out of view).
DOM virtualization is a necessary performance optimization when a large number of elements need to be
rendered into the browser, but only a small portion of them are visible at any given moment.
Good examples are long lists, or data heavy tables.
Diving straight into the implementationโ
caution
The implementation described below is a simplified version of how the react-virtualized-window
works. In particular the focus is on vertical virtualization and fixed sized items, just to keep
things simple.
To virtualize a DOM view we need to know a few pieces of information:
- The dimensions of the container where the items will be rendered
- The total height needed to render all the items in the container (i.e. how much space would be needed if we didn't virtualize the view)
- The dimensions of the individual items
The container dimensions are simply enough, for example, if you have the following:
<div style={{ height: 500 }}></div>
Then your container dimensions have a height of 500px
. Straightforward enough.
Now we need to compute the total height needed if we wanted to render every single item in our
window. For example, if we have a list of 1000 items, and each item takes 20px
height, then the total height
is 20,000px
.
Once we know the dimensions of our container, and the total space we need, we can create a window. How? Using some styles
export function EmptyWindow() {
return (
<div style={{ height: 250, overflow: "auto" }}>
<div style={{ height: 20_000 }}></div>
</div>
)
}
Notice we have created a window with enough space to hold 20000px
of content. Now we need to know
how to position items within our window. This entails knowing two pieces of information:
- Our current scroll offset from the top
- Which items we should render at that position
We can actually just work this out using simple math. For example,
- Assume we have a list of 2000 items
- Assume we know the scroll offset from the top is
2000px
- Each item takes up
20px
of height - Our window can hold upto
250px
of height
Then our first item in view will be 2000 / 20 === 100
, and the last item in view is 250 / 20 + 100 === 112.5
, we round
up, so call it 113
. So our start index would be 100
and our end index will be 113
. Instead of rendering all 2000
items,
we render only 13
. There is one small edge case to consider. We are rendering exactly what the window can contain. If the
user scrolls down a little, let say 10px
, it means one item will only be half in view, leaving a little space at the bottom where
the background of our window will show through. See the diagram below:
We can fix this by adding 1
to our end index, i.e. render one more item as a buffer, so we render 14
items instead of 13
.
info
The algorithm for determining our start and end indices is a little more complex when item sizes can vary. We are keeping things simple here. You can see the code for our approach here.
Next we need to get the scroll offset. How? With an onScroll
callback. An example with all of this in place is shown below:
caution
The example below is incomplete, hence may behave unexpectedly. This is a stop gap to the final implementation.
Scroll Top Offset: 0
Start Index: 0, End Index: 14
Code
const ITEM_HEIGHT = 20
const WINDOW_HEIGHT = 200
export function OffsetAdjustedWindow() {
const [topOffset, setTopOffset] = useState(0)
const data = useMemo(() => {
return Array.from({ length: 1000 }, (_, i) => i)
}, [])
const startIndex = Math.floor(topOffset / ITEM_HEIGHT)
const endIndex = Math.ceil(WINDOW_HEIGHT / ITEM_HEIGHT + startIndex) + 1
return (
<div>
<code>Scroll Top Offset: {topOffset}</code>
<code>
Start Index: {startIndex}, End Index: {endIndex}
</code>
<div
style={{ height: WINDOW_HEIGHT, overflow: "auto" }}
onScroll={(e) => setTopOffset(e.currentTarget.scrollTop)}
>
<div style={{ height: 20_000 }}>
{data.slice(startIndex, endIndex).map((row, i) => {
return (
<div
style={{
height: ITEM_HEIGHT,
maxHeight: ITEM_HEIGHT,
minHeight: ITEM_HEIGHT,
display: "flex",
border: "1px solid grey",
boxSizing: "border-box",
alignItems: "center",
justifyContent: "center",
}}
key={i + startIndex}
>
{row}
</div>
)
})}
</div>
</div>
</div>
)
}
Okay, so now we know what to render but it still doesn't quite work. We also need to position our items.
If we have scrolled 2000px
down, then we need to shift our list items 2000px
down. How? We can create
a div
that is out of view from the user but grows in size as the user scrolls. This div
will push our
content down. I call this the offset div
. Briefly:
<div style={{ height: 20_000 }}>
<div style={{ height: runningHeight }} />
{data.slice(startIndex, endIndex).map((row, i) => {
// Our data items
})}
</div>
Okay but what is runningHeight
then? It is the height of the number of items above our starting item. For example,
if our starting index is 100
, then runningHeight
is the height of the items from 0
to 99
. Instead of
the individual items, we render a single large div
with the height of all the individual items combined. That's it!
Check out the full working example below.
Scroll Top Offset: 0
Start Index: 0, End Index: 14
Code
export function VirtualizedWindow() {
const [topOffset, setTopOffset] = useState(0)
const data = useMemo(() => {
return Array.from({ length: 1000 }, (_, i) => i)
}, [])
const startIndex = Math.floor(topOffset / ITEM_HEIGHT)
const endIndex = Math.ceil(WINDOW_HEIGHT / ITEM_HEIGHT + startIndex) + 1
return (
<div>
<code>Scroll Top Offset: {topOffset}</code>
<code>
Start Index: {startIndex}, End Index: {endIndex}
</code>
<div
style={{
height: WINDOW_HEIGHT,
overflow: "auto",
}}
onScroll={(e) => setTopOffset(e.currentTarget.scrollTop)}
>
<div style={{ height: 20_000 }}>
<div style={{ height: ITEM_HEIGHT * startIndex }}></div>
{data.slice(startIndex, endIndex).map((row, i) => {
return (
<div
style={{
height: ITEM_HEIGHT,
minHeight: ITEM_HEIGHT,
maxHeight: ITEM_HEIGHT,
border: "1px solid grey",
boxSizing: "border-box",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
key={i + startIndex}
>
{row}
</div>
)
})}
</div>
</div>
</div>
)
}
info
The @resembli/react-virtualized-window
uses a div
that grows in size to move items into position
as the user scrolls. Other virtualization libraries use transform
or position
. Any of these approaches
work. The advantage of using a div
is that we can utilize the browser's normal capabilities for laying out
items, instead of individually positioning each item ourselves.
Preventing Content Flashesโ
All virtualization implementations result in content flashing in as the user scroll very quickly. The tldr reason for this is that scolling in the browser happens on a separate thread, whilst all the content calculations and painting happens on the main thread. Hence whilst the main thread is busy, scrolling can still happen. This is execellent article for those looking to understand more.
With that said, the @resembli/react-virtualized-window
components have a way to prevent content flashing in. For example, if we look at
react-window
vs @resembli/react-virtualized-window
:
react-window
:@resembli/react-virtualized-window
info
This is NOT a performance comparison, or even a comparison of react-window
to @resembli/react-virtualized-window
. This is a demonstration of
the checkboarding effect present in other virtualization
frameworks, which is not present in @resembli/react-virtualized-window
(unless you disable the stick div
using the disableSticky
prop).
And for those wondering, react-window
and @resembli/react-virtualized-window
are equally fast.
The question is how does this work? The answer is by using a div
with position: "sticky"
. We essentially need to keep
a <div>
in view, even as the browser scrolls. For example:
Code
export function StickyExample() {
return (
<div>
<div
style={{
height: WINDOW_HEIGHT,
overflow: "auto",
border: "1px solid grey",
}}
>
<div style={{ height: 20_000 }}>
<div style={{ position: "sticky", top: 0, left: 0 }}>
<div style={{ height: 200, width: 200, background: "red" }}></div>
</div>
</div>
</div>
</div>
)
}
Notice that the red box, always remains in the top left corner of the container. Regardless of the scroll position. If we replace the red box with our virtualized items, we get the following:
Scroll Top Offset: 0
Start Index: 0, End Index: 14
Code
export function VirtualizedWindowStickyBroken() {
const [topOffset, setTopOffset] = useState(0)
const data = useMemo(() => {
return Array.from({ length: 1000 }, (_, i) => i)
}, [])
const startIndex = Math.floor(topOffset / ITEM_HEIGHT)
const endIndex = Math.ceil(WINDOW_HEIGHT / ITEM_HEIGHT + startIndex) + 1
return (
<div>
<code>Scroll Top Offset: {topOffset}</code>
<code>
Start Index: {startIndex}, End Index: {endIndex}
</code>
<div
style={{
height: WINDOW_HEIGHT,
overflow: "auto",
}}
onScroll={(e) => setTopOffset(e.currentTarget.scrollTop)}
>
<div style={{ height: 20_000 }}>
<div style={{ position: "sticky", top: 0, left: 0 }}>
<div style={{ height: ITEM_HEIGHT * startIndex }}></div>
{data.slice(startIndex, endIndex).map((row, i) => {
return (
<div
style={{
height: ITEM_HEIGHT,
minHeight: ITEM_HEIGHT,
maxHeight: ITEM_HEIGHT,
border: "1px solid grey",
boxSizing: "border-box",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
key={i + startIndex}
>
{row}
</div>
)
})}
</div>
</div>
</div>
</div>
)
}
Fixing scroll offset with sticky divโ
This doesn't quite work! As we scroll, the offset div
that grows to push our items down will now be in view. So what can
we do? Simple, translate our entire container in the opposite direction our offset div
grows in. This is as simple as applying
transform: translate3d(0px, ${-topOffset}px, px)
to a div the wraps all our items. See this in action below:
Scroll Top Offset: 0
Start Index: 0, End Index: 14
Code
export function VirtualizedWindowStickyBroken2() {
const [topOffset, setTopOffset] = useState(0)
const data = useMemo(() => {
return Array.from({ length: 1000 }, (_, i) => i)
}, [])
const startIndex = Math.floor(topOffset / ITEM_HEIGHT)
const endIndex = Math.ceil(WINDOW_HEIGHT / ITEM_HEIGHT + startIndex) + 1
return (
<div>
<code>Scroll Top Offset: {topOffset}</code>
<code>
Start Index: {startIndex}, End Index: {endIndex}
</code>
<div
style={{
height: WINDOW_HEIGHT,
overflow: "auto",
}}
onScroll={(e) => setTopOffset(e.currentTarget.scrollTop)}
>
<div style={{ height: 20_000 }}>
<div style={{ position: "sticky", top: 0, left: 0 }}>
<div style={{ transform: `translate3d(0px, ${-topOffset}px, 0px)` }}>
<div style={{ height: ITEM_HEIGHT * startIndex }}></div>
{data.slice(startIndex, endIndex).map((row, i) => {
return (
<div
style={{
height: ITEM_HEIGHT,
minHeight: ITEM_HEIGHT,
maxHeight: ITEM_HEIGHT,
border: "1px solid grey",
boxSizing: "border-box",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
key={i + startIndex}
>
{row}
</div>
)
})}
</div>
</div>
</div>
</div>
</div>
)
}
Fixing content disappearing at halfway markโ
The above example looks like it works, but there is one more issue. If you scroll past the halfway mark
the content will disappear. I'll be honest, I am not quite sure why this is the case and I couldn't find
anything about it on the Google. BUT, I do know what fixes the issue. By making the div
that we are
translating as we scroll have position: "absolute"
. So the final working solution:
Scroll Top Offset: 0
Start Index: 0, End Index: 14
Code
export function VirtualizedWindowSticky() {
const [topOffset, setTopOffset] = useState(0)
const data = useMemo(() => {
return Array.from({ length: 1000 }, (_, i) => i)
}, [])
const startIndex = Math.floor(topOffset / ITEM_HEIGHT)
const endIndex = Math.ceil(WINDOW_HEIGHT / ITEM_HEIGHT + startIndex) + 1
return (
<div>
<code>Scroll Top Offset: {topOffset}</code>
<code>
Start Index: {startIndex}, End Index: {endIndex}
</code>
<div
style={{
height: WINDOW_HEIGHT,
overflow: "auto",
}}
onScroll={(e) => setTopOffset(e.currentTarget.scrollTop)}
>
<div style={{ height: 20_000 }}>
<div style={{ position: "sticky", top: 0, left: 0 }}>
<div
style={{
position: "absolute",
width: "100%",
top: 0,
transform: `translate3d(0px, ${-topOffset}px, 0px)`,
}}
>
<div style={{ height: ITEM_HEIGHT * startIndex }}></div>
{data.slice(startIndex, endIndex).map((row, i) => {
return (
<div
style={{
height: ITEM_HEIGHT,
minHeight: ITEM_HEIGHT,
maxHeight: ITEM_HEIGHT,
border: "1px solid grey",
boxSizing: "border-box",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
key={i + startIndex}
>
{row}
</div>
)
})}
</div>
</div>
</div>
</div>
</div>
)
}
Drawbacks of using the position sticky divโ
We are essentially making scrolling a synchronous operation (strictly speaking this isn't what's happening but the effect is the same).
So the biggest drawback is janky scrolling. If your application can't run at 60fps, or the device running your application is not
powerful enough things may not be as smooth as they could without the sticky
positioning. Furthermore, sticky
positioning has a few
quirks in older browsers, and isn't supports at all by IE11.
With that said, you can actually disable the sticky
position on the fly. You need to do two things for this:
- Conditionally render the
div
withsticky
positioning - If
sticky
position is not being used, do not do any translation