Simplifying Image Loading in React with Lazy Loading and Intersection Observer API

Published On

March 1, 2024

Author

Aniket Jadhav

Services

Simplifying Image Loading in React with Lazy Loading and Intersection Observer API

Overview

The online world is a whirlwind of activity, bringing people together across the globe in the blink of an eye. But as we embrace the latest in technology, we also raise the bar for speedy and smooth user experiences.  

Now, here's the catch – images. They're the visual gems that make a website shine, but they can also be the culprits behind slow loading times.  

To tackle this challenge head-on, web developers have come up with clever ways to enhance your online journey, especially when it comes to loading images. One such cool technique that's gaining popularity is called Lazy Loading. Let's dive in and explore how it can make your web adventures even better!

The Importance of Lazy Loading

An image is, as we’ve said, really important for a website. But it’s not very important until it actually is in the viewport. If there was an image at the end of this article, most of you would probably never have seen that image (clever way to auto-criticize my boring writing style), so most of you should not spend the time to download that image. Lazy loading is exactly this: an image is not downloaded unless it is actually in the viewport.

Intersection Observers: A Modern Solution

In the old days, developers added listeners on the scroll event to check if an image was in the viewport or not. However, in today's era, we have something way better: Intersection Observers! These intelligent observers provide a more efficient and less resource-intensive way to handle these scenarios.

Instead of relying on continuous scroll events, Intersection Observers are designed to notify your code when an element, like an image, enters or exits the viewport. This allows for a more streamlined approach to lazy loading. No more constant checking – let the Intersection Observer do the heavy lifting, and only load those images when they truly matter.

How Intersection Observer Works

The Intersection Observer is like a backstage pass for web developers, giving you the power to keep tabs on an element and get a heads-up whenever it struts into or out of the viewport. Super handy, right?  

Here's the lowdown on how it works – it's pretty slick and straightforward. First things first, you declare a brand-new Intersection Observer and spill the beans on what should happen in the callback and any special options:  

const options = {};
const observer = new IntersectionObserver((entries, obs) => {
for (const entry of entries) {
//entry.boundingClientRect
//entry.intersectionRatio
//entry.intersectionRect
//entry.isIntersecting
//entry.rootBounds
//entry.target
//entry.time
}
}, options);   
 

Let's break down the callback parameters:  

Entries : This is like your guest list, listing every element the observer is keeping an eye on. Each entry spills the beans on crucial details like where the element is in the viewport, how much of it is visible, and whether it's currently mingling with the screen.

  • boundingClientRect: A fancy term for the rectangle around the element that's visible in the viewport.
  • intersection Ratio: Tells you how much of the element is strutting its stuff inside the viewport.
  • isIntersection: Tells you whether the element is intersecting with the viewport or not.
  • rootBounds: If there's a declared root element in the options, this spills the beans on the boundingClientRect with respect to that root.
  • target : The rockstar element itself.
  • time: Time stamp of when this intersection was recorded.
 
Obs: This is like the observer's backstage pass. It lets you tweak the observer's behavior after the observation has been triggered.  

Now, here's a tiny heads-up – the Intersection Observer API has this quirky habit of firing the callback the first time each observed element does its thing, even if it starts off hanging out somewhere beyond the screen.  

With its bag of tricks, this API is your go-to buddy for handling dynamic changes in element visibility. It's the secret sauce for making cool things happen, like efficient lazy loading and other interactive web wizardry.  

For more details, check out the [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) on the MDN docs.

How to apply this with react?

Now that we've armed ourselves with this newfound knowledge, let's put it into action by creating a custom React hook and a versatile component. This dynamic duo will work together seamlessly, ensuring that an image is only loaded when it gracefully enters the user's viewport. No need to be intimidated – here's the breakdown.

We'll start by crafting a custom React hook

Crafting the Hook - Unleashing useIntersection

Now comes the exciting part! We're going to create a React hook that seamlessly integrates with the Intersection Observer API, and we'll name it `useIntersection`. This hook will be the powerhouse behind our lazy-loading mechanism.

Introducing the Players

Before diving into the coding frenzy, let's briefly meet the key players:

  • listenerCallbacks:A backstage manager, a `WeakMap` holding the association between DOM elements and their callback functions. Think of it as our VIP guest list.
  • observer: The star of our show, an instance of the Intersection Observer that will elegantly notify us when an element enters or exits the stage (viewport).
  •  
import { useEffect } from "react";
// Using a WeakMap to associate DOM elements with their respective callbacks
let listenerCallbacks = new WeakMap();  

// The Intersection Observer instance
let observer;

Meet the Callback Maestro - `handleIntersections`

Our callback function, `handleIntersections`, steals the spotlight as it processes a list of entries. It meticulously checks for associated callbacks and triggers them when an element is in view or has a significant intersection ratio. This is the maestro directing our lazy-loading orchestra.

// Callback function for handling intersections
function handleIntersections(entries) {
  entries.forEach((entry) => {
// Checking if there's a callback associated with the target element
if (listenerCallbacks.has(entry.target)) {
let cb = listenerCallbacks.get(entry.target);

// Triggering the callback if the element is intersecting or has an intersection ratio greater than 0
if (entry.isIntersecting || entry.intersectionRatio > 0) {
observer.unobserve(entry.target);
listenerCallbacks.delete(entry.target);
cb();
}
}
});
}

Summoning the Observer - `getIntersectionObserver`

Now, let's summon our Intersection Observer with the `getIntersectionObserver` function. This function creates the observer if it doesn't exist, setting the stage with a callback and some options.

// Function to get or create the Intersection Observer
function getIntersectionObserver() {
if (observer === undefined) {

// Creating the Intersection Observer with a callback and options
observer = new IntersectionObserver(handleIntersections, {
rootMargin: "100px",
threshold: 0.15,
});
}
return observer;
}

Crafting the Hook - `useIntersection`

And now, the grand reveal - our custom hook `useIntersection`. This masterpiece leverages the `useEffect` hook to orchestrate the Intersection Observer dance. It takes in two partners: `elem` (a ref to the DOM element) and `callback` (the function to be called when visibility changes).

export function useIntersection(elem, callback) {
useEffect(() => {

// Getting the DOM element from the ref
let target = elem.current;

// Getting or creating the Intersection Observer
let observer = getIntersectionObserver();

  // Associating the target element with its callback in the WeakMap
listenerCallbacks.set(target, callback);

   // Observing the target element
observer.observe(target);

  // Cleanup function to unobserve the target element when the component unmounts
return () => {
listenerCallbacks.delete(target);
observer.unobserve(target);
};
}, []);
}

There you have it! `useIntersection` is now your go-to maestro for orchestrating the lazy-loading symphony in your React components.

  import { useEffect } from "react";

// Using a WeakMap to associate DOM elements with their respective callbacks
let listenerCallbacks = new WeakMap();

// The Intersection Observer instance
let observer;

// Callback function for handling intersections
function handleIntersections(entries) {
entries.forEach((entry) => {

// Checking if there's a callback associated with the target element
if (listenerCallbacks.has(entry.target)) {
let cb = listenerCallbacks.get(entry.target);

// Triggering the callback if the element is intersecting or has an intersection ratio greater than 0
if (entry.isIntersecting || entry.intersectionRatio > 0) {
observer.unobserve(entry.target);
listenerCallbacks.delete(entry.target);
cb();
}
}
});
}

// Function to get or create the Intersection Observer
function getIntersectionObserver() {
if (observer === undefined) {

// Creating the Intersection Observer with a callback and options
observer = new IntersectionObserver(handleIntersections, {
rootMargin: "100px",
threshold: 0.15,
});
}
return observer;
}

// Custom hook for using Intersection Observer
export function useIntersection(elem, callback) {
useEffect(() => {

// Getting the DOM element from the ref
let target = elem.current;

// Getting or creating the Intersection Observer
let observer = getIntersectionObserver();

// Associating the target element with its callback in the WeakMap
listenerCallbacks.set(target, callback);

// Observing the target element
observer.observe(target);

// Cleanup function to unobserve the target element when the component unmounts
return () => {
listenerCallbacks.delete(target);
observer.unobserve(target);
};
}, []);
}

Putting It All Together - ImageRenderer Component

import { useIntersection } from "@/helper/hooks/intersectionObserver";
import React, { useState, useRef } from "react";

const ImageRenderer = ({ url, thumb, width, height }) => {

// State to track whether the original image is loaded const [isLoaded, setIsLoaded] = useState(false);

// State to track whether the component is in view
const [isInView, setIsInView] = useState(false);

// Ref to hold a reference to the DOM element
const imgRef = useRef();

// Custom hook to observe the element and set isInView state
useIntersection(imgRef, () => {
setIsInView(true);
  });

// Callback function to handle the original image load event
const handleOnLoad = () => {
setIsLoaded(true);
};

// JSX structure for the ImageRenderer component
return (

className={`image-container ${isLoaded && "remove-bg-color"}`}
ref={imgRef}
style={{
paddingBottom: `${(height / width) * 100}%`,
width: "100%"
}}
>

{/* Render images only if the component is in view */}
{isInView && (
<>

{/* Thumbnail image */}

className={`image thumb ${!!isLoaded && "isLoaded-thumb"}`}
src={thumb}
alt="Thumbnail"
/>

/* Original image */}

className={`image ${!!isLoaded && "isLoaded-original"}`}
src={url}
onLoad={handleOnLoad}
alt="Original"
/>

)}

);
};

export default ImageRenderer;

Using Our Component in a Parent Component

import ImageRenderer from "@/components/ImageRenderer";
import { useEffect, useState } from "react";

  export default function Home() {
const [photos, setPhotos] = useState(null);

useEffect(() => {
fetch("https://picsum.photos/v2/list?page=2&limit=100&height=200")
.then((res) => res.json())
.then((data) => setPhotos(data));
}, []);

return (

{photos &&
photos.map((data) => (

key={
url={data.download_url}
thumb={data.download_url}
width={300}
height={100}
/>
))}

);
}


Behold the magic of the Intersection Observer API! Now, only the images that enchant the viewport are summoned to appear. It's like the webpage has a built-in wizard that brings forth images precisely when you're ready to see them. User-friendly enchantment at its finest!

Wrapping Up the Enchantment

And that's a wrap! With the dynamic duo of React.js and the Intersection Observer API, we've conjured up a potent spell for lazy loading images – a surefire way to elevate performance and user delight. This enchanting approach ensures images materialize exactly when the user beckons, weaving a faster and more efficient web experience. As we continue our journey through the ever-evolving realm of web development, tools like Intersection Observers emerge as our trusted companions, helping us meet and exceed the lofty expectations of modern users. Until next time, may your web adventures be swift and enchanting!