β¨ Introducing react-cool-virtual: A tiny React hook for rendering large datasets like a breeze
Welly
Posted on June 4, 2021
When rendering a large set of data (e.g. list, table, etc.) in React, we all face performance/memory troubles. React Cool Virtual is a tiny React hook that gives you the best DX and modern way for virtualizing a large amount of data without struggle π€―.
Features
- β»οΈ Renders millions of items with highly performant way, using DOM recycling.
- π£ Easy to use, based on React hook.
- π πΌ Apply styles without hassle, just few setups.
- 𧱠Supports fixed, variable, dynamic, and real-time resize heights/widths.
- π₯ Supports RWD (responsive web design) for better UX.
- π Supports sticky headers for building on-trend lists.
- π Built-ins load more callback for you to deal with infinite scroll + skeleton screens.
- π± Imperative scroll-to methods for offset, items, and alignment.
- πΉ Out-of-the-box smooth scrolling and the effect is DIY-able.
- π¬ It's possible to implement stick to bottom and pre-pending items for chat, feeds, etc.
- β³ Provides
isScrolling
indicator to you for UI placeholders or performance optimization. - ποΈ Supports server-side rendering (SSR) for a fast FP + FCP and better SEO.
- π Supports TypeScript type definition.
- π Super flexible API design, built with DX in mind.
- π¦ Tiny size (~ 3.1kB gzipped). No external dependencies, aside for the
react
.
Usage
React Cool Virtual has a flexible API design, it can cover many use cases that you need. Let's see how does it rock!
Fixed Size
This example demonstrates how to create a fixed size row. For column or grid, please refer to CodeSandbox.
import useVirtual from "react-cool-virtual";
const List = () => {
const { outerRef, innerRef, items } = useVirtual({
itemCount: 1000,
});
return (
<div
style={{ width: "300px", height: "300px", overflow: "auto" }}
ref={outerRef}
>
<div ref={innerRef}>
{items.map(({ index, size }) => (
<div key={index} style={{ height: `${size}px` }}>
βοΈ {index}
</div>
))}
</div>
</div>
);
};
Variable Size
This example demonstrates how to create a variable size row. For column or grid, please refer to CodeSandbox.
import useVirtual from "react-cool-virtual";
const List = () => {
const { outerRef, innerRef, items } = useVirtual({
itemCount: 1000,
itemSize: (idx) => (idx % 2 ? 100 : 50),
});
return (
<div
style={{ width: "300px", height: "300px", overflow: "auto" }}
ref={outerRef}
>
<div ref={innerRef}>
{items.map(({ index, size }) => (
<div key={index} style={{ height: `${size}px` }}>
βοΈ {index}
</div>
))}
</div>
</div>
);
};
Dynamic Size
This example demonstrates how to create a dynamic (unknown) size row. For column or grid, please refer to CodeSandbox.
import useVirtual from "react-cool-virtual";
const List = () => {
const { outerRef, innerRef, items } = useVirtual({
itemCount: 1000,
itemSize: 75, // The unmeasured item sizes will refer to this value (default = 50)
});
return (
<div
style={{ width: "300px", height: "300px", overflow: "auto" }}
ref={outerRef}
>
<div ref={innerRef}>
{items.map(({ index, measureRef }) => (
// Use the `measureRef` to measure the item size
<div key={index} ref={measureRef}>
{/* Some data... */}
</div>
))}
</div>
</div>
);
};
Real Time Resize
This example demonstrates how to create a real-time resize row (e.g. accordion, collapse, etc.). For column or grid, please refer to CodeSandbox.
import { useState, forwardRef } from "react";
import useVirtual from "react-cool-virtual";
const AccordionItem = forwardRef(({ children, height, ...rest }, ref) => {
const [h, setH] = useState(height);
return (
<div
{...rest}
style={{ height: `${h}px` }}
ref={ref}
onClick={() => setH((prevH) => (prevH === 50 ? 100 : 50))}
>
{children}
</div>
);
});
const List = () => {
const { outerRef, innerRef, items } = useVirtual({
itemCount: 50,
});
return (
<div
style={{ width: "300px", height: "300px", overflow: "auto" }}
ref={outerRef}
>
<div ref={innerRef}>
{items.map(({ index, size, measureRef }) => (
// Use the `measureRef` to measure the item size
<AccordionItem key={index} height={size} ref={measureRef}>
ππ» Click Me
</AccordionItem>
))}
</div>
</div>
);
};
Responsive Web Design (RWD)
This example demonstrates how to create a list with RWD to provide a better UX for the user.
import useVirtual from "react-cool-virtual";
const List = () => {
const { outerRef, innerRef, items } = useVirtual({
itemCount: 1000,
// Use the outer's width (2nd parameter) to adjust the item's size
itemSize: (_, width) => (width > 400 ? 50 : 100),
// The event will be triggered on outer's size changes
onResize: (size) => console.log("Outer's size: ", size),
});
return (
<div
style={{ width: "100%", height: "400px", overflow: "auto" }}
ref={outerRef}
>
<div ref={innerRef}>
{/* We can also access the outer's width here */}
{items.map(({ index, size, width }) => (
<div key={index} style={{ height: `${size}px` }}>
βοΈ {index} ({width})
</div>
))}
</div>
</div>
);
};
Scroll to Offset/Items
You can imperatively scroll to offset or items as follows:
const { scrollTo, scrollToItem } = useVirtual();
const scrollToOffset = () => {
// Scrolls to 500px
scrollTo(500, () => {
// π€πΌ Do whatever you want through the callback
});
};
const scrollToItem = () => {
// Scrolls to the 500th item
scrollToItem(500, () => {
// π€πΌ Do whatever you want through the callback
});
// We can control the alignment of the item with the `align` option
// Acceptable values are: "auto" (default) | "start" | "center" | "end"
// Using "auto" will scroll the item into the view at the start or end, depending on which is closer
scrollToItem({ index: 10, align: "auto" });
};
Sticky Headers
This example demonstrates how to make sticky headers when using React Cool Virtual.
import useVirtual from "react-cool-virtual";
const List = () => {
const { outerRef, innerRef, items } = useVirtual({
itemCount: 1000,
itemSize: 75,
stickyIndices: [0, 10, 20, 30, 40, 50], // The values must be provided in ascending order
});
return (
<div
style={{ width: "300px", height: "300px", overflow: "auto" }}
ref={outerRef}
>
<div ref={innerRef}>
{items.map(({ index, size, isSticky }) => {
let style = { height: `${size}px` };
// Use the `isSticky` property to style the sticky item, that's it β¨
style = isSticky ? { ...style, position: "sticky", top: "0" } : style;
return (
<div key={someData[index].id} style={style}>
{someData[index].content}
</div>
);
})}
</div>
</div>
);
};
Smooth Scrolling
React Cool Virtual provides the smooth scrolling feature out of the box, all you need to do is turn the smooth
option on.
const { scrollTo, scrollToItem } = useVirtual();
// Smoothly scroll to 500px
const scrollToOffset = () => scrollTo({ offset: 500, smooth: true });
// Smoothly scroll to the 500th item
const scrollToItem = () => scrollToItem({ index: 10, smooth: true });
The default easing effect is easeInOutCubic, and the duration is 500 milliseconds. You can easily customize your own effect as follows:
const { scrollTo } = useVirtual({
// For 500 milliseconds
scrollDuration: 500,
// Or whatever duration you want based on the scroll distance
scrollDuration: (distance) => distance * 0.05,
// Using "easeInOutBack" effect (default = easeInOutSine), see: https://easings.net/#easeInOutSine
scrollEasingFunction: (t) => {
const c1 = 1.70158;
const c2 = c1 * 1.525;
return t < 0.5
? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
: (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
},
});
const scrollToOffset = () => scrollTo({ offset: 500, smooth: true });
π‘ For more cool easing effects, please check it out.
Infinite Scroll
It's possible to make a complicated infinite scroll logic simple by just using a hook, no kidding! Let's see how possible π€.
Working with Skeleton Screens
import { useState } from "react";
import useVirtual from "react-cool-virtual";
import axios from "axios";
const TOTAL_COMMENTS = 500;
const BATCH_COMMENTS = 5;
const isItemLoadedArr = [];
const loadData = async ({ loadIndex }, setComments) => {
// Set the state of a batch items as `true`
// to avoid the callback from being invoked repeatedly
isItemLoadedArr[loadIndex] = true;
try {
const { data: comments } = await axios(`/comments?postId=${loadIndex + 1}`);
setComments((prevComments) => [...prevComments, ...comments]);
} catch (err) {
// If there's an error set the state back to `false`
isItemLoadedArr[loadIndex] = false;
// Then try again
loadData({ loadIndex }, setComments);
}
};
const List = () => {
const [comments, setComments] = useState([]);
const { outerRef, innerRef, items } = useVirtual({
itemCount: TOTAL_COMMENTS,
// Estimated item size (with padding)
itemSize: 122,
// The number of items that you want to load/or pre-load, it will trigger the `loadMore` callback
// when the user scrolls within every items, e.g. 1 - 5, 6 - 10, and so on (default = 15)
loadMoreCount: BATCH_COMMENTS,
// Provide the loaded state of a batch items to the callback for telling the hook
// whether the `loadMore` should be triggered or not
isItemLoaded: (loadIndex) => isItemLoadedArr[loadIndex],
// We can fetch the data through the callback, it's invoked when more items need to be loaded
loadMore: (e) => loadData(e, setComments),
});
return (
<div
style={{ width: "300px", height: "300px", overflow: "auto" }}
ref={outerRef}
>
<div ref={innerRef}>
{items.map(({ index, measureRef }) => (
<div
key={comments[index]?.id || `fb-${index}`}
style={{ padding: "16px", minHeight: "122px" }}
ref={measureRef} // Used to measure the unknown item size
>
{comments[index]?.body || "β³ Loading..."}
</div>
))}
</div>
</div>
);
};
Working with A Loading Indicator
import { Fragment, useState } from "react";
import useVirtual from "react-cool-virtual";
import axios from "axios";
const TOTAL_COMMENTS = 500;
const BATCH_COMMENTS = 5;
const isItemLoadedArr = [];
// We only have 50 (500 / 5) batches of items, so set the 51th (index = 50) batch as `true`
// to avoid the `loadMore` callback from being invoked, yep it's a trick π
isItemLoadedArr[50] = true;
const loadData = async ({ loadIndex }, setComments) => {
isItemLoadedArr[loadIndex] = true;
try {
const { data: comments } = await axios(`/comments?postId=${loadIndex + 1}`);
setComments((prevComments) => [...prevComments, ...comments]);
} catch (err) {
isItemLoadedArr[loadIndex] = false;
loadData({ loadIndex }, setComments);
}
};
const Loading = () => <div>β³ Loading...</div>;
const List = () => {
const [comments, setComments] = useState([]);
const { outerRef, innerRef, items } = useVirtual({
itemCount: comments.length, // Provide the number of comments
loadMoreCount: BATCH_COMMENTS,
isItemLoaded: (loadIndex) => isItemLoadedArr[loadIndex],
loadMore: (e) => loadData(e, setComments),
});
return (
<div
style={{ width: "300px", height: "300px", overflow: "auto" }}
ref={outerRef}
>
<div ref={innerRef}>
{items.length ? (
items.map(({ index, measureRef }) => {
const showLoading =
index === comments.length - 1 && comments.length < TOTAL_COMMENTS;
return (
<Fragment key={comments[index].id}>
<div ref={measureRef}>{comments[index].body}</div>
{showLoading && <Loading />}
</Fragment>
);
})
) : (
<Loading />
)}
</div>
</div>
);
};
Prepending Items
This example demonstrates how to pre-pend items and maintain scroll position for the user.
import { useEffect, useLayoutEffect, useState } from "react";
import useVirtual from "react-cool-virtual";
import axios from "axios";
const TOTAL_COMMENTS = 500;
const BATCH_COMMENTS = 5;
let shouldFetchData = true;
let postId = 100;
const fetchData = async (postId, setComments) => {
try {
const { data: comments } = await axios(`/comments?postId=${postId}`);
// Pre-pend new items
setComments((prevComments) => [...comments, ...prevComments]);
} catch (err) {
// Try again
fetchData(postId, setComments);
}
};
const List = () => {
const [comments, setComments] = useState([]);
const { outerRef, innerRef, items, startItem } = useVirtual({
// Provide the number of comments
itemCount: comments.length,
onScroll: ({ scrollForward, scrollOffset }) => {
// Tweak the threshold of data fetching that you want
if (!scrollForward && scrollOffset < 50 && shouldFetchData) {
fetchData(--postId, setComments);
shouldFetchData = false;
}
},
});
useEffect(() => fetchData(postId, setComments), []);
// Execute the `startItem` through `useLayoutEffect` before the browser to paint
// See https://reactjs.org/docs/hooks-reference.html#uselayouteffect to learn more
useLayoutEffect(() => {
// After the list updated, maintain the previous scroll position for the user
startItem(BATCH_COMMENTS, () => {
// After the scroll position updated, re-allow data fetching
if (comments.length < TOTAL_COMMENTS) shouldFetchData = true;
});
}, [comments.length, startItem]);
return (
<div
style={{ width: "300px", height: "500px", overflow: "auto" }}
ref={outerRef}
>
<div ref={innerRef}>
{items.length ? (
items.map(({ index, measureRef }) => (
// Used to measure the unknown item size
<div key={comments[index].id} ref={measureRef}>
{comments[index].body}
</div>
))
) : (
<div className="item">β³ Loading...</div>
)}
</div>
</div>
);
};
Sticking to Bottom
This example demonstrates the scenario of sticking/unsticking the scroll position to the bottom for a chatroom.
import { useState, useEffect } from "react";
import useVirtual from "react-cool-virtual";
import axios from "axios";
const TOTAL_MESSAGES = 200;
let isScrolling = false; // Used to prevent UX conflict
let id = 0;
const loadData = async (id, setMessages) => {
try {
const { data: messages } = await axios(`/messages/${id}`);
setMessages((prevMessages) => [...prevMessages, messages]);
} catch (err) {
loadData(id, setMessages);
}
};
const Chatroom = () => {
const [shouldSticky, setShouldSticky] = useState(true);
const [messages, setMessages] = useState([]);
const { outerRef, innerRef, items, scrollToItem } = useVirtual({
// Provide the number of messages
itemCount: messages.length,
// Speed up smooth scrolling
scrollDuration: 50,
onScroll: ({ userScroll }) => {
// If the user scrolls and isn't automatically scrolling, cancel stick to bottom
if (userScroll && !isScrolling) setShouldSticky(false);
},
});
useEffect(() => {
// Mock messages service
if (id <= TOTAL_MESSAGES)
setTimeout(
() => loadData(++id, setMessages),
Math.floor(500 + Math.random() * 2000)
);
}, [messages.length]);
useEffect(() => {
// Automatically stick to bottom, using smooth scrolling for better UX
if (shouldSticky) {
isScrolling = true;
scrollToItem({ index: messages.length - 1, smooth: true }, () => {
isScrolling = false;
});
}
}, [messages.length, shouldSticky, scrollToItem]);
return (
<div>
<div
style={{ width: "300px", height: "400px", overflow: "auto" }}
ref={outerRef}
>
<div ref={innerRef}>
{items.map(({ index, measureRef }) => (
// Used to measure the unknown item size
<div key={`${messages[index].id}`} ref={measureRef}>
<div>{messages[index].content}</div>
</div>
))}
</div>
</div>
{!shouldSticky && (
<button onClick={() => setShouldSticky(true)}>Stick to Bottom</button>
)}
</div>
);
};
SSR
Server-side rendering allows us to provide a fast FP and FCP, it also benefits for SEO. React Cool Virtual supplies you a seamless DX between SSR and CSR.
const List = () => {
const { outerRef, innerRef, items } = useVirtual({
itemCount: 1000,
ssrItemCount: 30, // Renders 0th - 30th items on SSR
// or
ssrItemCount: [50, 80], // Renders 50th - 80th items on SSR
});
return (
<div
style={{ width: "300px", height: "300px", overflow: "auto" }}
ref={outerRef}
>
<div ref={innerRef}>
{/* The items will be rendered both on SSR and CSR, depending on our settings */}
{items.map(({ index, size }) => (
<div key={someData[index].id} style={{ height: `${size}px` }}>
{someData[index].content}
</div>
))}
</div>
</div>
);
};
Performance Optimization
Items are re-rendered whenever the user scrolls. If your item is a heavy data component, there're two strategies for performance optimization.
Use React.memo
When working with non-dynamic size, we can extract the item to it's own component and wrap it with React.memo
. It shallowly compares the current props and the next props to avoid unnecessary re-renders.
import { memo } from "react";
import useVirtual from "react-cool-virtual";
const MemoizedItem = memo(({ height, ...rest }) => {
// A lot of heavy computing here... π€ͺ
return (
<div {...rest} style={{ height: `${height}px` }}>
π³ Am I heavy?
</div>
);
});
const List = () => {
const { outerRef, innerRef, items } = useVirtual({
itemCount: 1000,
itemSize: 75,
});
return (
<div
style={{ width: "300px", height: "300px", overflow: "auto" }}
ref={outerRef}
>
<div ref={innerRef}>
{items.map(({ index, size }) => (
<MemoizedItem key={index} height={size} />
))}
</div>
</div>
);
};
Use isScrolling
Indicator
If the above solution can't meet your case or you're working with dynamic size. React Cool Virtual supplies you an isScrolling
indicator that allows you to replace the heavy component with a light one while the user is scrolling.
import { forwardRef } from "react";
import useVirtual from "react-cool-virtual";
const HeavyItem = forwardRef((props, ref) => {
// A lot of heavy computing here... π€ͺ
return (
<div {...props} ref={ref}>
π³ Am I heavy?
</div>
);
});
const LightItem = (props) => <div {...props}>π¦ I believe I can fly...</div>;
const List = () => {
const { outerRef, innerRef, items } = useVirtual({
itemCount: 1000,
useIsScrolling: true, // Just use it (default = false)
// or
useIsScrolling: (speed) => speed > 50, // Use it based on the scroll speed (more user friendly)
});
return (
<div
style={{ width: "300px", height: "300px", overflow: "auto" }}
ref={outerRef}
>
<div ref={innerRef}>
{items.map(({ index, isScrolling, measureRef }) =>
isScrolling ? (
<LightItem key={index} />
) : (
<HeavyItem key={index} ref={measureRef} />
)
)}
</div>
</div>
);
};
Thanks for reading, for more usage details check out the project's GitHub page: https://github.com/wellyshen/react-cool-virtual
You can also install this package is distributed viaΒ npm.
$ yarn add react-cool-virtual
# or
$ npm install --save react-cool-virtual
Posted on June 4, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.