When working on web applications, we often display lists of data. If the data is not particularly lengthy, we are probably okay with rendering all elements at once. However, as our data grows, we might notice performance issues. In this article, we create a simple React application that aims to render thousands of images using a virtualization technique.
Determining the issue
First, let’s create an application that fetches a list of photos and displays them. To do that, we will use JSONPlaceholder, which exposes an endpoint that returns five thousand entries.
For starters, we create a hook that fetches the data.
usePhotos.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import { useEffect, useState } from 'react'; import Photo from './Photo'; function usePhotos() { const [photos, setPhotos] = useState<Photo[] | null>(null); useEffect(() => { fetch('https://jsonplaceholder.typicode.com/photos?_limit=1000') .then((response) => response.json()) .then((photosData) => { setPhotos(photosData); }); }, []); return { photos, }; } export default usePhotos; |
Above, we use ?_limit=1000 to imit the number of photos to a thousand for now. Trying to render more is very likely to crash our browser.
Each one of our photos has a set of simple properties.
Photo.tsx
1 2 3 4 5 6 7 8 |
interface Photo { id: number; title: string; url: string; thumbnailUrl: string; } export default Photo; |
Our list is straightforward and renders one component per photo.
PhotosList.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
import React from 'react'; import styles from './styles.module.scss'; import usePhotos from './usePhotos'; import PhotoCard from './PhotoCard/PhotoCard'; const PhotosList = () => { const { photos } = usePhotos(); if (!photos) { return null; } return ( <div className={styles.wrapper}> {photos.map((photo) => ( <PhotoCard key={photo.id} photo={photo} /> ))} </div> ); }; export default PhotosList; |
For each photo, we render the thumbnail and the title.
PhotoCard.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import React, { FunctionComponent } from 'react'; import Photo from '../Photo'; import styles from './PhotoCard.module.scss'; interface Props { photo: Photo; } const PhotoCard: FunctionComponent<Props> = ({ photo }) => { return ( <div className={styles.wrapper}> <a href={photo.url}> <img src={photo.thumbnailUrl} alt={photo.title} /> </a> <p className={styles.title}>{photo.title}</p> </div> ); }; export default PhotoCard; |
Measuring the performance
While the above solution works, it is far from optimal. Rendering thousands of images at once causes the browser to make thousands of HTTP GET requests to fetch all of the data. Therefore, it is going to cause issues, especially on mobile.
Also, rendering thousands of elements in the DOM tree can slow down the initial rendering of our website and cause the scrolling to stutter.
Let’s run a Lighthouse audit to measure the performance of our application.
If you want to know more about Lighhouse, check out Improving our performance, accessibility, and SEO with Lighthouse audits
Unfortunately, the above score is not very good. So, let’s try improving it.
Introducing virtualization
If we render a large list, the user does not see all its contents at once and uses a scrollbar. When we implement virtualization, we don’t render the elements of the list that are not currently visible. By doing that, we make the DOM tree creation a lot faster. Besides that, the browser does not need to fetch all the images simultaneously.
To implement virtualization in this article, we use the react-window library.
1 |
npm install react-window @types/react-window |
The react-window library is a lighter, more recent alternative for the react-virtualized package created by the same author.
Since every element of our list is the same size, we can use the FixedSizeList component provided by the react-window library.
PhotosList.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import React from 'react'; import usePhotos from './usePhotos'; import PhotoCard from './PhotoCard/PhotoCard'; import { FixedSizeList } from 'react-window'; const PhotosList = () => { const { photos } = usePhotos(); if (!photos) { return null; } return ( <FixedSizeList height={800} width={600} itemCount={photos.length} itemSize={155}> {({ index, style }) => { const photo = photos[index]; return <PhotoCard key={photo.id} photo={photo} style={style} />; }} </FixedSizeList> ); }; export default PhotosList; |
FixedSizeList expects us to provide it with a component that renders an element of the list using the current index.
The component used for rendering a row might be a good canditate for memoization.
This component also gets the style prop used for positioning, so let’s modify our PhotoCard component to accommodate that.
PhotoCardtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import React, { CSSProperties, FunctionComponent } from 'react'; import Photo from '../Photo'; import styles from './PhotoCard.module.scss'; interface Props { photo: Photo; style?: CSSProperties; } const PhotoCard: FunctionComponent<Props> = ({ photo, style }) => { return ( <div className={styles.wrapper} style={style}> <a href={photo.url}> <img src={photo.thumbnailUrl} alt={photo.title} /> </a> <p className={styles.title}>{photo.title}</p> </div> ); }; export default PhotoCard; |
Thanks to the react-window library, we no longer render all elements at once.
The above, combined with the fact that the browser no longer fetches all of the images, immediately improves our performance significantly.
Sizing the list automatically
So far, we’ve had to provide the width and height of the list explicitly. If we want the list to be sized automatically, we need the react-virtualized-auto-sizer library.
1 |
npm install react-virtualized-auto-sizer @types/react-virtualized-auto-sizer |
The react-virtualized-auto-sizer library does not support React 18, currently.
To let the list grow to fill all available space, we need to use the AutoSizer component.
PhotosList.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
import React from 'react'; import usePhotos from './usePhotos'; import PhotoCard from './PhotoCard/PhotoCard'; import { FixedSizeList } from 'react-window'; import AutoSizer from 'react-virtualized-auto-sizer'; const PhotosList = () => { const { photos } = usePhotos(); if (!photos) { return null; } return ( <AutoSizer> {({ height, width }) => ( <FixedSizeList height={height} width={width} itemCount={photos.length} itemSize={155} > {({ index, style }) => { const photo = photos[index]; return <PhotoCard key={photo.id} photo={photo} style={style} />; }} </FixedSizeList> )} </AutoSizer> ); }; export default PhotosList; |
We can use AutoSizer to manage only width or height instead of both. To do that, we need to use the disableHeight or disableWidth attributes.
A crucial thing to notice is that the AutoSizer expands to fill the parent, but it does not stretch it. In our case, to deal with it in the simplest way possible, we can enlarge the #root div.
1 2 3 4 |
#root { width: 100vw; height: 100vh; } |
Implementing pagination
So far, we’ve fetched all data in one big request. However, to improve the performance more and handle the data of any length, we can implement infinite scrolling. The idea is to fetch additional data every time the user scrolls to the end of the list.
To do that, we need the react-window-infinite-loader package.
1 |
npm install react-window-infinite-loader @types/react-window-infinite-loader |
For the library to work, we need to use the InfiniteLoader component.
PhotosList.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
import React from 'react'; import usePhotos from './usePhotos'; import PhotoCard from './PhotoCard/PhotoCard'; import { FixedSizeList } from 'react-window'; import AutoSizer from 'react-virtualized-auto-sizer'; import InfiniteLoader from 'react-window-infinite-loader'; const PhotosList = () => { const { photos, loadChunkOfData, checkIfPhotoLoaded } = usePhotos(); return ( <InfiniteLoader isItemLoaded={checkIfPhotoLoaded} loadMoreItems={loadChunkOfData} itemCount={Infinity} > {({ onItemsRendered, ref }) => ( <AutoSizer ref={ref}> {({ height, width }) => ( <FixedSizeList height={height} width={width} itemCount={photos.length} itemSize={155} onItemsRendered={onItemsRendered} > {({ index, style }) => { const photo = photos[index]; return <PhotoCard key={photo.id} photo={photo} style={style} />; }} </FixedSizeList> )} </AutoSizer> )} </InfiniteLoader> ); }; export default PhotosList; |
The InfiniteLoader needs three properties:
- isItemLoaded is a function that returns true if an element with a given index is loaded,
- loadMoreItems is a function that fetches the data as soon as the user scrolls down,
- itemCount is a number of rows in the list that can be an arbitrarily high number if the exact number of rows is unknown.
Let’s modify our usePhotos hook to accommodate for the properties the InfiniteLoader needs.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
import { useCallback, useEffect, useRef, useState } from 'react'; import Photo from './Photo'; const elementsPerPage = 20; function usePhotos() { const [isLoading, setIsLoading] = useState(false); const [hasNextPage, setHasNextPage] = useState(true); const [photos, setPhotos] = useState<Photo[]>([]); const didFetchInitialData = useRef(false); const loadChunkOfData = useCallback(async () => { if (isLoading) { return; } setIsLoading(true); const pageNumber = photos.length / elementsPerPage; const response = await fetch( `https://jsonplaceholder.typicode.com/photos?${new URLSearchParams({ _start: String(elementsPerPage * pageNumber), _limit: String(elementsPerPage), })}`, ); const photosData = await response.json(); if (photosData.length < elementsPerPage) { setHasNextPage(false); } setPhotos([...photos, ...photosData]); setIsLoading(false); }, [isLoading, photos]); const checkIfPhotoLoaded = (index: number) => { return !hasNextPage || index < photos.length; }; useEffect(() => { if (!didFetchInitialData.current) { didFetchInitialData.current = true; loadChunkOfData(); } }, [loadChunkOfData]); return { photos, loadChunkOfData, checkIfPhotoLoaded, }; } export default usePhotos; |
Thanks to the above changes, our application loads batches of elements as the user scrolls the list.
Summary
In this article, we’ve tackled the issue of rendering huge lists in React. To do that, we’ve used the react-window library. We’ve also learned how to integrate it with react-virtualized-auto-sizer and react-window-infinite-loader. Thanks to that, we’ve achieved an infinitely-loading list and improved the performance measured with the Lighthouse audit.