✨ Introducing react-cool-virtual: A tiny React hook for rendering large datasets like a breeze
Tags: react, hook, virtualized, opensource
Published: 2021-06-04 14:57:46

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](https://bundlephobia.com/result?p=react-cool-virtual) 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](https://developers.google.com/web/updates/2016/07/infinite-scroller).
- 🎣 Easy to use, based on React [hook](https://reactjs.org/docs/hooks-custom.html#using-a-custom-hook).
- 💅🏼 Apply styles without hassle, just [few setups](#fixed-size).
- 🧱 Supports [fixed](#fixed-size), [variable](#variable-size), [dynamic](#dynamic-size), and [real-time resize](#real-time-resize) heights/widths.
- 🖥 Supports [RWD (responsive web design)](#responsive-web-design-rwd) for better UX.
- 📌 Supports [sticky headers](#sticky-headers) for building on-trend lists.
- 🚚 Built-ins [load more callback](#infinite-scroll) for you to deal with infinite scroll + [skeleton screens](https://uxdesign.cc/what-you-should-know-about-skeleton-screens-a820c45a571a).
- 🖱 Imperative [scroll-to methods](#scroll-to-offsetitems) for offset, items, and alignment.
- 🛹 Out-of-the-box [smooth scrolling](#smooth-scrolling) and the effect is DIY-able.
- 💬 It's possible to implement [stick to bottom](#sticking-to-bottom) and [pre-pending items](#prepending-items) for chat, feeds, etc.
- ⛳ Provides `isScrolling` indicator to you for UI placeholders or [performance optimization](#use-isscrolling-indicator).
- 🗄️ Supports [server-side rendering (SSR)](#ssr) for a fast [FP + FCP](https://developers.google.com/web/updates/2019/02/rendering-on-the-web#server-rendering) and better [SEO](https://developers.google.com/web/updates/2019/02/rendering-on-the-web#server-rendering).
- 📜 Supports [TypeScript](https://www.typescriptlang.org) type definition.
- 🎛 Super flexible [API](https://github.com/wellyshen/react-cool-virtual#api) design, built with DX in mind.
- 🦔 Tiny size ([~ 3.1kB gzipped](https://bundlephobia.com/result?p=react-cool-virtual)). No external dependencies, aside for the `react`.

## Usage

React Cool Virtual has a flexible [API](https://github.com/wellyshen/react-cool-virtual#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.

[![Edit RCV - Fixed Size](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/rcv-fixed-size-bowcu?fontsize=14&hidenavigation=1&theme=dark)

```js
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.

[![Edit RCV - Variable Size](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/rcv-variable-size-8vu3u?fontsize=14&hidenavigation=1&theme=dark)

```js
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.

[![Edit RCV - Dynamic Size](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/rcv-dynamic-size-0wurg?fontsize=14&hidenavigation=1&theme=dark)

```js
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.

[![Edit RCV - Real-time Resize](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/rcv-real-time-resize-fixvr?fontsize=14&hidenavigation=1&theme=dark)

```js
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.

[![Edit RCV - RWD](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/rcv-rwd-x6lvc?fontsize=14&hidenavigation=1&theme=dark)

```js
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:

[![Edit RCV - Scroll-to Methods](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/rcv-scroll-to-methods-cs9x9?fontsize=14&hidenavigation=1&theme=dark)

```js
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.

[![Edit RCV - Sticky Headers](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/rcv-sticky-headers-pm79x?fontsize=14&hidenavigation=1&theme=dark)

```js
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.

[![Edit RCV - Smooth Scrolling](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/rcv-smooth-scrolling-noy4d?fontsize=14&hidenavigation=1&theme=dark)

```js
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](https://easings.net/#easeInOutCubic), and the duration is 500 milliseconds. You can easily customize your own effect as follows:

```js
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](https://easings.net).

## 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 🤔.

[![Edit RCV - Infinite Scroll](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/rcv-infinite-scroll-3y351?fontsize=14&hidenavigation=1&theme=dark)

### Working with [Skeleton Screens](https://uxdesign.cc/what-you-should-know-about-skeleton-screens-a820c45a571a)

```js
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

```js
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.

[![Edit RCV - Prepend Items](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/rcv-prepend-items-ui06h?fontsize=14&hidenavigation=1&theme=dark)

```js
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.

[![Edit RCV - Stick to Bottom](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/rcv-stick-to-bottom-cxsgw?fontsize=14&hidenavigation=1&theme=dark)

```js
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](https://developers.google.com/web/updates/2019/02/rendering-on-the-web#server-rendering), it also benefits for [SEO](https://developers.google.com/web/updates/2019/02/rendering-on-the-web#seo). React Cool Virtual supplies you a seamless DX between SSR and CSR.

```js
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](https://reactjs.org/docs/react-api.html#reactmemo)

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.

```js
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.

```js
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](https://www.npmjs.com/package/react-cool-virtual).

```sh
$ yarn add react-cool-virtual
# or
$ npm install --save react-cool-virtual
```