Make your virtualized list

murilovarela

Murilo Varela

Posted on January 27, 2020

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.

💖 💪 🙅 🚩
murilovarela
Murilo Varela

Posted on January 27, 2020

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

Sign up to receive the latest update from our blog.

Related

Make your virtualized list
react Make your virtualized list

January 27, 2020