logo

Lazy Loading Images with IntersectionObserver and React Hooks

EDITED: From Chrome 76 onwards, Chrome supports native lazy loading for images 🎉🎉🎉! Example: <img loading="lazy" />

Images have always been one of the primary culprits for slow websites. Loading all images at once not only affects the load time but also exhausts users’ unnecessary bandwidth. One of the solutions for optimising images is Lazy Loading. In other words, websites should delay fetching images from their source before they are in the view of the users. https://web.dev/browser-level-image-lazy-loading/ Today I am going to share with you guys a simple approach to lazy load images in your React apps with IntersectionObserver API and React Hooks.

IntersectionObserver

IntersectionObserver is a web API that allows developers to detect visibility of an element, or the relative visibility of two elements in relation to each other.

Note : IntresectionObserver API is not available for all browsers. Check caniuse for more details.

React Hooks

Hooks is a new feature which allows developers to upgrade their functional components with lifecycles, states and many more without transforming the dumb components to classes. Check out my blog for a brief introduction to React Hooks.


Implementation

Function to create a new Observer

function createObserver(inViewCallback = noop, newOptions = {}) {
  const defaultOptions = {
    root: null,
    rootMargin: '0px',
    threshold: 0.3,
  }
  return new IntersectionObserver(inViewCallback, Object.assign(defaultOptions, newOptions));
}

This function returns an instance of IntersectionObserver. The class’s constructor takes two parameters:

  • a callback function
  • options object

    • root is an element to be used as its viewport. It defaults to the browser’s viewport when it receives null or it is not specified.
    • rootMargin sets the margin to the bounding box of the root element before doing any calculation.
    • threshold takes in a number or array of numbers ranging from 0 to 1. The number represents the intersection of the viewport (root element) and the target element. When you want the callback function to be executed when the target element is 25% in view, its threshold should be set to 0.25. If you want the function to execute every time the viewport passes another 25% of the target element, the threshold should be set to [0, 0.25, 0.5, 0.75, 1].

Image Component

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

const LazyImage = ({
  observer,
  src,
  alt,
}) => {
  const imageEl = useRef(null);

  useEffect(() => {
    const {current} = imageEl;
    
    if (observer !== null) { 
        observer.observe(current); 
    }
    
    return () => {
      observer.unobserve(current);
    }
  }, [observer]);

  return (
      <img
        ref={imageEl}
        data-src={src}
        alt={alt}
    />
  )
}
export default LazyImage;
  • LazyImage component

    • This will be our component for lazy loading an image.
    • It uses two React Hooks: useEffect and useRef.
    • useEffect is used to mimic the effects of componentDidMount which is, in our use case, to ensure imageEl has the element’s reference before the img element is observed.
    • Inside the useEffect function, we use an Observer which is passed as a prop to observe the image element. The image element is retrieve as a reference with the useRef function.
    • I mentioned in my other article that the second parameter of useEffect is used to avoid re-execution of the function.
    • The second parameter that we are providing as an array contains the Observer prop. It is to ensure that useEffect will stop being called only after the Observer prop has a value.

Usage

import React, {useEffect, useState} from 'react';
import LazyImage from './LazyImage';

function onImageInView(entries, observer) {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      const element = entry.target;
      const imageSrc = element.getAttribute('data-src');
      
      element.removeAttribute('data-src');
      element.setAttribute('src', imageSrc);
      
      observer.unobserve(element);
    } 
  });
}

const Parent = ({images}) => {
  const [imageObserver, setImageObserver] = useState(null);
  
  useEffect(() => {
    const imageObserver = createObserver(onImageInView); 
    setImageObserver(imageObserver);
    
    return () => {
      imageObserver.disconnect();
    }
  }, []);
  
  
  return images.map(({src, alt}, index) => (
    <LazyImage key={index} observer={imageObserver} src={src} alt={alt} />
  ))
}

Let’s break it down:

  • onImageView function

    • This function encapsulates our business logic for when our observed elements are in view. The function will be provided to the createObserver function as its callback.
    • This function is called whenever an observed element satisfies the threshold.
    • Entries is an array of all the observed elements and the Observer is provided mainly for removing elements from the list when you are done observing them.
    • Take note that any code that runs inside the forEach function should be fast. Any time-consuming code should be queued using the Window.requestIdleCallback() function.
    • In the loop, we are only selecting the elements that have satisfied the threshold by checking the isIntersecting field.
  • Parent component

    • This would be the component where you render your LazyImage component(s).
    • Using the useEffect function, we initialise our observer and store it to a state variable using a function provided by the useState function.
    • We pass and empty array to prevent the useEffect function is being called every time the Parent component is re-rendered.
    • Finally, the component is rendering an array of images for lazy loading with our LazyImage component.