Lazy loading React components

Lazy loading React components

Render a component only when it is visible on the screen

Over the life of an application, performance optimization can play a crucial role in its success or failure and I recently encountered an issue that severely impacted the user experience.

Providing some context, my requirement was to show a list of N items using React. Unfortunately, I couldn’t use infinite scrolling, and each item needed to fetch its own data via separate HTTP calls.

Initially, I successfully implemented a component able to fetch data during initialization, nothing difficult, but performance was really bad when there were many items on screen and as previously mentioned I could not use infinite scrolling or pagination due to backend limitations, leaving frontend adjustments as the sole option.

Setup

Let’s walk through a sample project that illustrates the problem and demonstrates a solution.

I chose:

  • Javascript: To avoid compiling so we focus on our goal. This same approach works with typescript as well.

  • https://jsonplaceholder.typicode.com: For fetching sample data. It's a "Free fake and reliable API for testing and prototyping."

  • Vite: Recommended after create-react-app deprecation.

  • npm: So that everyone can feel comfortable.

You’ll find the link to the complete project at the end of this post, but let’s cover all the steps here.

Our goal is to initiate the (potentially) resource-intensive fetch only when the component becomes visible to the user, otherwise we would be performing unnecessary work that could compromise the overall performance.

Let's start creating a new react project using Vite.

❯ npm create vite@latest
✔ Project name: … react-render-visible
✔ Select a framework: › React
✔ Select a variant: › JavaScript

Scaffolding project in react-render-visible...

First, we need a very simple component that fetches data from APIs and renders it in DOM. This is how it initially looks:

import React, { useState, useEffect } from 'react';

const NinjaComponent = () => {
    const [data, setData] = useState(null);

    useEffect(() => {
        // fetch data
    }, []);

    return (
        <div>
        {/* Show data */}
        </div>
    );
};

export default NinjaComponent;

Now, let's add some logic retrieving fake data from https://jsonplaceholder.typicode.com. It offers a set of basic CRUD operations hence we are going to implement a GET by id provided as input parameter of the component.

Example: https://jsonplaceholder.typicode.com/photos/1

// 1) Add `id` parameter
const NinjaComponent = ({ id }) => {
    const [data, setData] = useState(null);

// 2) Fetch data using `id` in url and update `data`
    useEffect(() => {
        fetch(`https://jsonplaceholder.typicode.com/photos/${id}`)
          .then((response) => response.json())
          .then((data) => setData(data))
          .catch((error) => console.error("Error:", error));
    }, []);

// 3) If `data` is set we render id, title and an image, else it shows a loading text
  return (
    <div style={{border: '1px dashed yellow'}}>
      {data ? (<>
        <p>{data.id}</p>
        <p>{data.title}</p>
        <img src={data.thumbnailUrl} />
      </>
      ) : (
        <p>Loading...</p>
      )}
    </div>
  );
};

We can add it to our home page to check how it works. Let's make the necessary edits in App.jsx.

import "./App.css";
import NinjaComponent from "./components/NinjaComponent";

function App() {
  return (
    <>
      <h1>Ding Dong Bug</h1>
      <div className="card">
        <NinjaComponent id={1} />
      </div>
    </>
  );
}

export default App;

With the initial setup complete, we’ve built our data component and integrated it into the homepage to display sample data. Now, let’s address a performance concern.
Time to start the project: npm run dev

Project startup

Good, but I've got 1000 elements!

While the NinjaComponent works well, it’s not exactly stealthy. What if we need to render a ton of that?

Try updating the App.jsx with 500 or 1000 instances of NinjaComponent.

...
{Array.from({ length: 1000 }, (_, index) => (
    <NinjaComponent id={index+1} />
))}
...

After relaunching the project, check the console:

Requests and resources with 1000 elements

This is an insane amount of wasted resources especially if you are not "using" all those components because only the first one or two are on screen.

Solution: IntersectionObserver

Screen boundaries and elements

The green area represents what the user actually sees on the screen, while the red area includes elements already in DOM, but currently off-screen.

To manage on-screen visibility, the IntersectionObserver API comes to our aid. It "provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport" so basically it can determine exactly what we need.

More in detail we will use two methods provided by IntersectionObserver:

  • The observe() method is like telling the IntersectionObserver to start keeping an eye on a specific element. When the element’s visibility changes in relation to the viewport or a specified ancestor element, the IntersectionObserver will take note and execute its callback function. This function will receive an array of IntersectionObserverEntry objects, each representing a visibility change event.

  • The disconnect() method tells the IntersectionObserver to stop watching all of its target elements. It’s like saying, “You can rest now, no need to track any more changes.” This is particularly useful when you no longer need to monitor the visibility changes of the target elements.

With this in mind it is possible to proceed creating a custom hook that will be integrated in NinjaComponent so that it knows when fetch can start effectively filling data.

import { useEffect, useState } from "react";

const useFetchOnVisible = (ref, fetchFn) => {
  const [data, setData] = useState(null); // the state to store the fetched data
  const [loading, setLoading] = useState(false); // the state to indicate the loading status

  useEffect(() => {
    // create a new intersection observer
    const observer = new IntersectionObserver(
      (entries) => {
        // get the first entry
        const entry = entries[0];

        // if the entry is visible and not loading (and we still don't have data)
        if (entry.isIntersecting && !loading && !data) {
          // set the loading state to true
          setLoading(true);

          // call the fetch function and set the data state
          fetchFn().then((data) => {
            console.log("data", data);
            setData(data);
            setLoading(false);
          });
        }
      },
      { threshold: 0.1 }, // the ratio of the element's area that is visible
    );

    // observe the ref element
    observer.observe(ref.current);

    // unobserve the ref element when the component unmounts
    return () => {
      observer.disconnect();
    };
  }, [ref, data, fetchFn, loading]); // the dependencies of the effect

  // return the data and loading state
  return [data, loading];
};

export default useFetchOnVisible;

The threshold in IntersectionObserver is like a visibility marker for your target element. It tells the IntersectionObserver when to notify you about the visibility changes of the target element.

  • A threshold of 0.0 means that even a single visible pixel counts as the target being visible.

  • A threshold of 1.0 means that the entire target element must be visible for the callback to be invoked.

With hook available we just need to update the NinjaComponent behaviour moving the fetch function inside the hook itself.

import React, { useRef } from "react";
import useFetchOnVisible from "../hooks/useFetchOnVisible";

const NinjaComponent = ({ id }) => {
  // `ref` places a target on object to track 
  const ref = useRef(null);

  // fetch function has been moved out of the useEffect hook.
  // it just fetches data and parse json.
  const getSource = () =>
    fetch(`https://jsonplaceholder.typicode.com/photos/${id}`) // replace with your API endpoint
      .then((response) => response.json());

  // !!! use the custom hook with `ref` and fetch function
  const [data, loading] = useFetchOnVisible(ref, getSource);

  // UI does not change, it just add the `ref` tag in order to track div
  return (
    <div ref={ref} style={{ border: "1px dashed yellow" }}>
      {data && !loading ? (
        <>
          <p>{data.id}</p>
          <p>{data.title}</p>
          <img src={data.thumbnailUrl} />
        </>
      ) : (
        <p>Loading...</p>
      )}
    </div>
  );
};

export default NinjaComponent;

Ready to test?

Lazy loading in action

Assuming everything is properly configured, let’s open the page and observe what happens in the console. We’ll pay close attention to loading times and downloaded resources.

Request and resources with lazy loading

The page opening now requires few requests, consumes fewer resources, and the total loading time has dramatically improved — from around 10 seconds to a mere 0.5 seconds!

Lazy loading while scrolling

As we scroll down the page, the IntersectionObserver detects the moment when a component becomes visible and triggers the fetch function to retrieve data as needed.

Even increasing the number of components to 2000, the request count at startup remains unchanged and performance does not suffer. All thanks to fetching data precisely when it’s required, rather than preemptively! 🎉

GitHub: Source code