DEV Community

Cover image for Make your virtualized list
Murilo Varela
Murilo Varela

Posted on • Edited on

Make your virtualized list

There are a few libraries available if you need to virtualize long lists and improve your page performance. The biggest problem I faced with those libraries is that they are too heavy and sometimes our lists are not as simple as the library is expecting it to be! So let's make customized virtualization list ourselves!

The virtualization principle

Do not render it if it is not in our field of view. The picture below exemplifies how we can deal with it.

virtualization diagram

Let's code!

You can start by downloading the problematic code from here, and follow the solution thinking.

1. The problem

In this project, I faced a page with a large number of product cards that was fine for restaurants, which doesn't have many products, but once that same page started to be used by large grocery stores with thousands of products, the page became slow and our challenge is to virtualize that page to make user experience better.

2. The solution

To begin we need a function that will tell us if the components in our list are visible or not. We can achieve that by checking if:

1) the distance from the bottom of our component to the top of the page is greater than zero; and

top + offset + height >= 0
Enter fullscreen mode Exit fullscreen mode

2) the distance from the top of our component to the bottom of the page is less than the page height.

top - offset <= window.innerHeight
Enter fullscreen mode Exit fullscreen mode

That means our component is inside the visible part of our browser's page.

function isVisible({ top, offset, height }) {
  return top + offset + height >= 0 && top - offset <= window.innerHeight;
}
Enter fullscreen mode Exit fullscreen mode

3. Listening to the scroll event

Now that we know the math to calculate if the component is visible, it's time to attach a function to listen to the scroll event.

useEffect(() => {
  const isInViewportListener = isInViewport;
  window.addEventListener('scroll', isInViewportListener, false);
  isInViewport();
  return () => {
    window.removeEventListener('scroll', isInViewportListener, false);
  };
}, [isInViewport]);
Enter fullscreen mode Exit fullscreen mode

4. Referencing the category container

With the useRef hook, we have access to the container rect information that is needed in the isVisible function, so we can set its visibility to a state.

const  [isContainerVisible, setIsContainerVisible] =  useState(index  <  2);
const  containerRef  =  useRef(undefined);

const isInViewport = useCallback(() => {
  const offset = 250;
  let containerVisibility = false;
  if (containerRef.current) {
    const { top, height } = containerRef.current.getBoundingClientRect();
    containerVisibility = isVisible({ top, offset, height });
  }
  setIsContainerVisible(containerVisibility);
}, [items]);
Enter fullscreen mode Exit fullscreen mode

5. Calculating the category container hight

To avoid having the page changing height, we must calculate the container height. In this case, we have a grid with two columns and each card with 260px of height and a gap of 30px.

const listHeight = useMemo(() => {
  const gridColumns = 2;
  const itemHeight = 160;
  const gap = 10;
  const lastGap = 10;
  const containerHeight =
    Math.ceil(items.length / gridColumns - 1) * gap + lastGap + Math.ceil(items.length / gridColumns) * itemHeight;
  return { containerHeight, itemHeight };
}, [items.length]);
Enter fullscreen mode Exit fullscreen mode

And if we add a hook to listen for resizing, we can make ir work with the responsivity. The code for useResizeObserver is found in here.

const  [wrapperRef, wrapperWidth] =  useResizeObserver();

const listHeight = useMemo(() => {
  const isMobile = wrapperWidth < 650;
  const gridColumns = isMobile ? 1 : 2;
  const itemHeight = 160;
  const gap = isMobile ? 0 : 10;
  const lastGap = isMobile ? 0 : 10;
  const containerHeight =
    Math.ceil(items.length / gridColumns - 1) * gap + lastGap + Math.ceil(items.length / gridColumns) * itemHeight;
  return { containerHeight, itemHeight };
}, [items.length, wrapperWidth]);
Enter fullscreen mode Exit fullscreen mode

6. Virtualizing the items

The items ref must be an array, so we can evaluate all of then at each isInViewport call easily.

const allCardsInvisible = useMemo(() => {
  const cardVisibility = [];
  for (let i = 0; i < items.length; i += 1) cardVisibility[i] = false;
  return cardVisibility;
}, [items.length]);
const [isContainerVisible, setIsContainerVisible] = useState(index < 2);
const [isCardVisible, setIsCardVisible] = useState(allCardsInvisible);
const containerRef = useRef(undefined);
const cardRef = useRef(allCardsInvisible);

const isInViewport = useCallback(() => {
  const offset = 250;
  let containerVisibility = false;
  if (containerRef.current) {
    const { top, height } = containerRef.current.getBoundingClientRect();
    containerVisibility = isVisible({ top, offset, height });
  }
  setIsContainerVisible(containerVisibility);

  let cardsVisibility = allCardsInvisible;
  if (containerVisibility) {
    cardsVisibility = items.map((_, itemIndex) => {
      const card = cardRef.current[itemIndex];

      if (card) {
        const { top, height } = card.getBoundingClientRect();
        return isVisible({ top, offset, height });
      }

      return false;
    });
  }
  setIsCardVisible(cardsVisibility);
}, [allCardsInvisible, items]);
Enter fullscreen mode Exit fullscreen mode

The final component

With a few lines of code, we have made ourselves a virtualized list! There is way more room for improvement, but the point is proved! It is not that hard!

Working solution

import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import PropTypes from 'prop-types';
import useResizeObserver from '../hooks/useResizeObserver';
import Card from './Card';
import './category.css';

function isVisible({ top, offset, height }) {
  return top + offset + height >= 0 && top - offset <= window.innerHeight;
}
function Category({ title, description, items, index }) {
  const allCardsInvisible = useMemo(() => {
    const cardVisibility = [];
    for (let i = 0; i < items.length; i += 1) cardVisibility[i] = false;
    return cardVisibility;
  }, [items.length]);
  const [isContainerVisible, setIsContainerVisible] = useState(index < 2);
  const [isCardVisible, setIsCardVisible] = useState(allCardsInvisible);
  const containerRef = useRef(undefined);
  const cardRef = useRef(allCardsInvisible);
  const [wrapperRef, wrapperWidth] = useResizeObserver();

  const listHeight = useMemo(() => {
    const isMobile = wrapperWidth < 650;
    const gridColumns = isMobile ? 1 : 2;
    const itemHeight = 160;
    const gap = isMobile ? 0 : 10;
    const lastGap = isMobile ? 0 : 10;
    const containerHeight =
      Math.ceil(items.length / gridColumns - 1) * gap + lastGap + Math.ceil(items.length / gridColumns) * itemHeight;
    return { containerHeight, itemHeight };
  }, [items.length, wrapperWidth]);

  const isInViewport = useCallback(() => {
    const offset = 250;
    let containerVisibility = false;
    if (containerRef.current) {
      const { top, height } = containerRef.current.getBoundingClientRect();
      containerVisibility = isVisible({ top, offset, height });
    }
    setIsContainerVisible(containerVisibility);

    let cardsVisibility = allCardsInvisible;
    if (containerVisibility) {
      cardsVisibility = items.map((_, itemIndex) => {
        const card = cardRef.current[itemIndex];

        if (card) {
          const { top, height } = card.getBoundingClientRect();
          return isVisible({ top, offset, height });
        }

        return false;
      });
    }

    setIsCardVisible(cardsVisibility);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [allCardsInvisible, items, wrapperWidth]);

  useEffect(() => {
    const isInViewportListener = isInViewport;
    window.addEventListener('scroll', isInViewportListener, false);
    isInViewport();
    return () => {
      window.removeEventListener('scroll', isInViewportListener, false);
    };
  }, [isInViewport]);

  return (
    <div className="category" ref={wrapperRef}>
      <div className="category__title">{title}</div>
      <div className="category__description">{description}</div>
      <ul className="category__container" style={{ height: listHeight.containerHeight }} ref={containerRef}>
        {isContainerVisible &&
          items.map((item, cardIndex) => (
            <li
              key={`menu-item-${item.id}`}
              style={{ height: listHeight.itemHeight }}
              ref={ref => {
                cardRef.current[cardIndex] = ref;
              }}
            >
              {isCardVisible[cardIndex] && <Card {...item} />}
            </li>
          ))}
      </ul>
    </div>
  );
}

Category.propTypes = {
  description: PropTypes.string.isRequired,
  index: PropTypes.number.isRequired,
  items: PropTypes.array.isRequired,
  title: PropTypes.string.isRequired,
};

export default Category;
Enter fullscreen mode Exit fullscreen mode

One final touch of improvement is to delay the image rendering with a timeout, so it doesn't get rendered while scrolling very fast.

import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './card.css';

function Card({ title, imageUrl, description, price, isSale }) {
  const [loadImage, setLoadImage] = useState(false);
  useEffect(() => {
    const timer = setTimeout(() => {
      setLoadImage(true);
    }, 300);
    return () => clearTimeout(timer);
  }, []);

  return (
    <div className="card">
      {loadImage && <img className="card__image" alt="" src={imageUrl} />}
      <div className="card__title">{title}</div>
      <div className="card__description">{description}</div>
      <div className="card__price">{price}</div>
      {isSale && <div className="card__is-sale">50% OFF!</div>}
    </div>
  );
}

Card.propTypes = {
  description: PropTypes.string.isRequired,
  imageUrl: PropTypes.string.isRequired,
  isSale: PropTypes.bool.isRequired,
  price: PropTypes.string.isRequired,
  title: PropTypes.string.isRequired,
};

export default Card;
Enter fullscreen mode Exit fullscreen mode

How better is our page

The page went from 33124 to 1078 dom-nodes, an improvement of 3000% on dom-nodes count! As seen on google documentation a page should not have more than 1500 nodes wich can reflect drastically on performance.

To improve performance we can call the throttle the isInViewport with 16ms, what means it gets called once each 16ms, or 60 times per second, matching the screen update rate.

Adding fewer listeners would also improve page performance. Now we are adding 100 listeners to the scroll, which may not be a very good approach, once it can slow down the page, but still not as bad as 33k dom-nodes being rendered at the same time.

Top comments (4)

Collapse
 
cayanoglu profile image
Cüneyt Ayanoğlu

Good article, this is what I need to begin building a virtual list. Thanks

Collapse
 
fladson profile image
Fladson Gomes

Nice! Good job.

Collapse
 
andrewmat profile image
André Matulionis

What is the third argument false in the addEventListener function?

window.addEventListener('scroll', isInViewportListener, false);
Collapse
 
jeduardo824 profile image
Eduardo Alencar

I think it is the useCapture parameter. It is a Boolean indicating whether events of this type will be dispatched to the registered listener before being dispatched to any EventTarget beneath it in the DOM tree. Events that are bubbling upward through the tree will not trigger a listener designated to use capture.

developer.mozilla.org/en-US/docs/W...
w3.org/TR/DOM-Level-3-Events/#even...