✨ Introducing react-cool-virtual: A tiny React hook for rendering large datasets like a breeze

wellyshen

Welly

Posted on June 4, 2021

✨ Introducing react-cool-virtual: A tiny React hook for rendering large datasets like a breeze

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

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.

Edit RCV - Fixed Size

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

Variable Size

This example demonstrates how to create a variable size row. For column or grid, please refer to CodeSandbox.

Edit RCV - Variable Size

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

Scroll to Offset/Items

You can imperatively scroll to offset or items as follows:

Edit RCV - Scroll-to Methods

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" });
};
Enter fullscreen mode Exit fullscreen mode

Sticky Headers

This example demonstrates how to make sticky headers when using React Cool Virtual.

Edit RCV - Sticky Headers

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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

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 });
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ 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 πŸ€”.

Edit RCV - Infinite Scroll

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

Prepending Items

This example demonstrates how to pre-pend items and maintain scroll position for the user.

Edit RCV - Prepend Items

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
πŸ’– πŸ’ͺ πŸ™… 🚩
wellyshen
Welly

Posted on June 4, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related