Skip to content

Instantly share code, notes, and snippets.

@limgit
Last active April 7, 2022 05:55
Show Gist options
  • Select an option

  • Save limgit/00f88d626e313d5dd4c8446c4db18e51 to your computer and use it in GitHub Desktop.

Select an option

Save limgit/00f88d626e313d5dd4c8446c4db18e51 to your computer and use it in GitHub Desktop.
WIP: Full screen react list virtualizer with infinite scrolling
import React from 'react';
const GUESSED_HEIGHT = 80; // Initial height value we will use for unknown itme height
type Data = {
id: number,
value: string,
};
type VirtualScrollerProps = {
dataList: Data[], // Data to render
loadingMore: boolean, // Is data loading more?
hasNext: boolean, // Does next data exist?
onLoadMore: () => void, // Callback to load more data
};
const VirtualScroller: React.FC<VirtualScrollerProps> = ({
dataList, loadingMore, hasNext, onLoadMore,
}) => {
const itemsRef = React.useRef<(HTMLDivElement | null)[]>([]);
type ItemHeight = {
[idx: number]: number,
};
const [itemHeights, setItemHeights] = React.useState<ItemHeight>({});
const [visible, setVisible] = React.useState({
startIdx: 0,
// For initial value, we assume all items have GUESSED_HEIGHT height value.
// In such case, we can see Math.ceil(document.documentElement.clientHeight / GUESS_HEIGHT) items in screen
// Multiply it by 2 for additional buffering of items so client can draw next items beforehand
count: Math.ceil(document.documentElement.clientHeight / GUESSED_HEIGHT) * 2,
});
const updateItemHeights = () => {
const newV: ItemHeight = {};
const { startIdx, count } = visible;
let needUpdate = false;
dataList.slice(startIdx, startIdx + count).forEach((_, idx) => {
const originalIdx = startIdx + idx;
// Get rendered item height
const elHeight = itemsRef.current[originalIdx]?.offsetHeight;
// Update item height if needed
if (elHeight !== undefined && itemHeights[originalIdx] !== elHeight) {
needUpdate = true;
newV[originalIdx] = elHeight;
}
});
if (needUpdate) {
setItemHeights({
...itemHeights,
...newV,
});
}
};
// Hook for adjusting ref array size
React.useEffect(() => {
itemsRef.current = itemsRef.current.slice(0, dataList.length);
}, [dataList.length]);
// On every render, recalculate item heights and if it changes, we render the component again
React.useEffect(() => {
updateItemHeights();
});
// Registering resize event handler
React.useEffect(() => {
const onResize = () => {
// Maybe we will need rAF for event throttling
// Recalculate item heights on resize
updateItemHeights();
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
});
// Registering scroll event handler
React.useEffect(() => {
const onScroll = () => {
const { scrollHeight, scrollTop, clientHeight } = document.documentElement;
// Check if user reaches the end of scroll. If so, load more items
if (scrollTop + clientHeight >= scrollHeight && !loadingMore && hasNext) {
onLoadMore();
}
// Calculate visible items and set it if it changes
setVisible((prev) => {
let sum = 0;
let sIdx = 0;
let count = 0;
for (let i = 0; i < dataList.length; i += 1) {
sum += itemHeights[i] ?? GUESSED_HEIGHT;
if (sum <= scrollTop) sIdx = i;
if (sum > scrollTop && sum <= scrollTop + clientHeight) count += 1;
}
// For buffering, we will load some more items before/after visible section
const newStart = Math.max(0, sIdx - Math.floor(count / 2));
const newCount = count * 2;
if (newStart !== prev.startIdx || newCount !== prev.count) {
return {
startIdx: newStart,
count: newCount,
};
}
return prev;
});
};
window.addEventListener('scroll', onScroll);
return () => {
window.removeEventListener('scroll', onScroll);
};
});
// Hook for loading items if item list does not overflow
React.useEffect(() => {
const sH = document.documentElement.scrollHeight;
const cH = document.documentElement.clientHeight;
if (cH >= sH && !loadingMore && hasNext) {
// Data does not overflow the screen. Load more.
onLoadMore();
}
});
// Sum item heights until idx (exclusive)
const sumHeightUntil = (idx: number) => new Array(idx).fill(null).reduce<number>(
(acc, _, i) => acc + (itemHeights[i] ?? GUESSED_HEIGHT),
0,
);
const { startIdx, count } = visible;
return (
<>
<div style={{ position: 'relative', height: sumHeightUntil(dataList.length) }}>
{dataList.slice(startIdx, startIdx + count).map((data, partialIdx) => {
const originalIdx = startIdx + partialIdx;
const { id, value } = data;
return (
<div
key={id}
ref={(el) => { itemsRef.current[originalIdx] = el; }}
style={{
position: 'absolute',
left: 0,
top: sumHeightUntil(originalIdx),
}}
>
{value}
</div>
);
})}
</div>
{hasNext && (
<div style={{ textAlign: 'center' }}>
{loadingMore ? 'Loading...' : 'Scroll to load more'}
</Box>
)}
</>
);
};
export default VirtualScroller;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment