Lazy loading images with React

In this post we will be exploring a technique for lazy loading images in React that makes use of the IntersectionObserver browser API.

The end goal is to build a drop-in replacement for the <img /> tag that defers loading the actual image file until it's needed.

TLDR?

If you prefer to jump straight in to the code, you can visit the final implementation on GitHub.

I've also published a library to npm called afterimage if you want to just use it!


Why?

Deferring the load of off-screen images can significantly improve page performance, and since there's very little detriment to the user experience, it's a no-brainer.


How?

We'll be creating a drop-in replacement for <img /> that does the following:

  1. Creates a IntersectionObserver (or reuses a cached one)
  2. Configures the IntersectionObserver to apply the src attribute to the <img /> tag when the image is at least 10% in the viewport
  3. Creates a React component that takes in props that extends the default <img /> tag props but does not apply the src attribute on the <img /> tag during first render
  4. Renders a placeholder element when the image is not loaded to prevent image jumping
  5. When the component mounts, adds it to the IntersectionObserver
  6. Applies some default styling for 💅

Let's tackle each of these in turn.


1. Creating the IntersectionObserver

Create a IntersectionObserver (or reuses a cached one)

The foundation of our lazy load image component is an IntersectionObserver which allows us to know when the image has entered the viewport. We'll create a simple JavaScript function that creates the observer and caches it on the window so that if there are many images, they all use the same observer. And, to ensure backwards compatibility with older browsers that do not support IntersectionObserver, we'll exit early.

const CACHE_KEY = "__AFTER_IMAGE_INTERSECTION_OBSERVER__";

function getImageLoaderObserver(): null | IntersectionObserver {
  // exit if browser does not support IntersectionObserver
  if (typeof IntersectionObserver === "undefined") {
    return null;
  }

  // return the cached observer for performance
  if (typeof window[CACHE_KEY] !== "undefined") {
    return window[CACHE_KEY];
  }

  // create a new observer and cache it on the window
  window[CACHE_KEY] = new IntersectionObserver(images => {
    entries.map(entry => {
      // TODO: implement the lazy load behavior in step 2
    });
  });
  return window[CACHE_KEY];
}

2. Configuring the IntersectionObserver

Configure the IntersectionObserver to apply the src attribute to the <img /> tag when the image is at least 10% in the viewport

To make sure that we aren't performing unnecessary work, we will ensure that we only apply the src attribute if it has not already been set, and we will put measures in place to ensure that we only apply the src attribute if it is provided and there is an <img /> tag available.

const threshold = 0.1; // 10% in view
window[CACHE_KEY] = new IntersectionObserver(
  entries => {
    entries.map(entry => {
      const img = entry.target.querySelector("img");
      if (img && !img.src && entry.intersectionRatio >= threshold) {
        const src = img.getAttribute("data-src");
        if (src) {
          img.src = src;
        }
      }
    });
  },
  {
    threshold
  }
);

We get the src from another attribute, data-src on the img tag. This lets the IntersectionObserver read from the DOM so it can be cached on the window, agnostic from the React component we'll create in a moment.

The line where we query for the <img /> tag is necessary to achieve placeholder styling, we'll come back to why in more detail in step 4.


3. Creating the React component

Create a React component that takes in props that extends the default <img /> tag props

This step is fairly simple and sets up the React component that we'll be extending with lazy-load behavior in step 4. The only interesting thing here is how we destructure this.props in the render method to pull out the src attribute and place it as data-src and then spread the remaining rest props on the <img /> tag. This means that the <img /> tag will not have a src attribute on the first-render.

interface Props extends React.ImgHTMLAttributes<HTMLImageElement> {
  src: string;
}

interface State {}

export class AfterImage extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = {};
  }

  render() {
    const { src, ...imgProps } = this.props;

    return <img {...imgProps} data-src={src} />;
  }
}

4. Placeholder element

Render a placeholder element when the image is not loaded to prevent image jumping

This step is more of a nice-to-have, but to ensure a nice user-experience it's a good idea to render a placeholder element that assumes the eventual size of the image when it has been loaded so that the content below the image does not jump when the image does indeed load.

To achieve this, we'll use the aspect ratio box technique and expose some props to configure it.

interface Props extends React.ImgHTMLAttributes<HTMLImageElement> {
  src: string;
  aspectHeight?: number;
  aspectWidth?: number;
  withPlaceholder?: boolean;
}

interface State {}

export class AfterImage extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = {};
  }

  render() {
    const {
      src,
      aspectHeight = 9,
      aspectWidth = 16,
      withPlaceholder = true,
      ...imgProps
    } = this.props;
    const aspectRatio = aspectHeight / aspectWidth;

    return (
      <div style={{ position: "relative" }}>
        <img
          {...imgProps}
          data-src={src}
          style={{
            width: "100%",
            height: "auto",
            position: withPlaceholder ? "absolute" : "static",
            left: 0
          }}
        />
        {withPlaceholder && (
          <div
            style={{
              width: "100%",
              paddingTop: `${aspectRatio * 100}%`
            }}
          />
        )}
      </div>
    );
  }
}

I'm a big fan of sensible defaults, and for that reason, the placeholder element defaults to a 16:9 size, but can be changed on a per-use basis.


5. Observing the component

When the component mounts, add it to the IntersectionObserver

The last piece in the puzzle is to connect the React component to the observer. Doing so involves using React's lifecycle methods to achieve the following:

  • Obtain a reference to the observer when the component mounts and store it in the class
  • During render, obtain references to the wrapping and image DOM nodes and store them in the class
  • When both DOM node references have been obtained, add the wrapping element to the observer
  • If there's no observer it means that there isn't any browser support - immediately apply the src attribute if this happens
  • Remove the component from the observer when the component unmounts to prevent memory leaks
export class AfterImage extends React.Component<Props, State> {
  imgElm: null | HTMLImageElement = null;
  wrapper: null | HTMLDivElement = null;
  observer: null | IntersectionObserver = null;

  constructor(props: Props) {
    super(props);

    this.state = {
      hasLoaded: false
    };
  }

  /**
   * Grab a reference to the IntersectionObserver and
   * store it on the instance.
   */
  componentDidMount() {
    this.observer = getImageLoaderObserver();
  }

  /**
   * Remove the instance from the IntersectionObserver
   * to prevent memory leaks.
   */
  componentWillUnmount() {
    if (this.observer && this.wrapper) {
      // for memory leaks
      this.observer.unobserve(this.wrapper);
    }
  }

  /**
   * Adds the current instance to the IntersectionObserver
   * if created and all React refs exist.
   *
   * Backwards compatible for browsers without support.
   */
  addImageToObserver = () => {
    if (this.observer && this.wrapper) {
      this.observer.observe(this.wrapper);
    } else if (this.imgElm && this.props.src) {
      // for browser compatibility
      this.imgElm.src = this.props.src;
    }
  };

  /**
   * Called when a required React ref is stored on the
   * class instance.
   */
  onRefStored = () => {
    if (this.wrapper && this.imgElm) {
      window.requestAnimationFrame(this.addImageToObserver);
    }
  };

  /**
   * Set afterimage wrapper elm React ref on the instance.
   *
   * Wrapper elm is necessary so that the IntersectionObserver
   * works (needs an element with at least 1px by 1px).
   */
  setWrapperRef = (elm: any) => {
    this.wrapper = ReactDOM.findDOMNode(elm) as any;
    this.onRefStored();
  };

  /**
   * Set img tag elm React ref on the instance.
   */
  setImgRef = (elm: any) => {
    this.imgElm = ReactDOM.findDOMNode(elm) as any;
    this.onRefStored();
  };

  render() {
    const {
      src,
      className = "",
      aspectHeight = 9,
      aspectWidth = 16,
      withPlaceholder = true,
      ...imgProps
    } = this.props;
    const { hasLoaded } = this.state;
    const aspectRatio = aspectHeight / aspectWidth;

    // data-src is needed so that the cached intersection observer can read the src from the wrapper and set it on the img tag

    return (
      <div
        className={`afterimage ${
          hasLoaded ? "afterimage--loaded" : ""
        } ${className}`}
        ref={this.setWrapperRef}
      >
        <img
          {...imgProps}
          className="afterimage__image"
          onLoad={this.onImageLoad}
          ref={this.setImgRef}
          data-src={src}
          style={{
            width: "100%",
            height: "auto",
            opacity: hasLoaded ? 1 : 0,
            transition: "opacity 300ms ease",
            position: withPlaceholder ? "absolute" : "static",
            left: 0
          }}
        />
        {withPlaceholder && (
          <div
            className="afterimage__placeholder"
            style={{
              width: "100%",
              paddingTop: `${aspectRatio * 100}%`,
              opacity: hasLoaded ? 0 : 1,
              transition: "opacity 300ms ease",
              pointerEvents: "none"
            }}
          />
        )}
      </div>
    );
  }
}

6. Adding some styling

Applies some default styling for 💅

The last thing we'll do is add some default styling so that when the image loads, it transitions in nicely. To do so, we will add inline styles but allow overriding through CSS class names.

...
render() {
  const {
    src,
    className = "",
    aspectHeight = 9,
    aspectWidth = 16,
    withPlaceholder = true,
    ...imgProps
  } = this.props;
  const { hasLoaded } = this.state;
  const aspectRatio = aspectHeight / aspectWidth;

  // data-src is needed so that the cached intersection observer can read the src from the wrapper and set it on the img tag

  return (
    <div
      className={`afterimage ${
        hasLoaded ? "afterimage--loaded" : ""
      } ${className}`}
      ref={this.setWrapperRef}
    >
      <img
        {...imgProps}
        className="afterimage__image"
        onLoad={this.onImageLoad}
        ref={this.setImgRef}
        data-src={src}
        style={{
          width: "100%",
          height: "auto",
          opacity: hasLoaded ? 1 : 0,
          transition: "opacity 300ms ease",
          position: withPlaceholder ? "absolute" : "static",
          left: 0
        }}
      />
      {withPlaceholder && (
        <div
          className="afterimage__placeholder"
          style={{
            width: "100%",
            paddingTop: `${aspectRatio * 100}%`,
            opacity: hasLoaded ? 0 : 1,
            transition: "opacity 300ms ease",
            pointerEvents: 'none'
          }}
        />
      )}
    </div>
  );
}

Conclusion

So in a few small steps, we've achieved a (nearly) drop-in replacement for the standard <img /> tag that lazily loads images for performance. And here's the final code!

import * as React from "react";
import * as ReactDOM from "react-dom";

interface Props extends React.ImgHTMLAttributes<HTMLImageElement> {
  src: string;
  className?: string;
  onLoad?: () => any;
  aspectHeight?: number;
  aspectWidth?: number;
  withPlaceholder?: boolean;
}

interface State {
  hasLoaded: boolean;
}

export class AfterImage extends React.Component<Props, State> {
  imgElm: null | HTMLImageElement = null;
  wrapper: null | HTMLDivElement = null;
  observer: null | IntersectionObserver = null;

  constructor(props: Props) {
    super(props);

    this.state = {
      hasLoaded: false
    };
  }

  /**
   * Grab a reference to the IntersectionObserver and
   * store it on the instance.
   */
  componentDidMount() {
    this.observer = getImageLoaderObserver();
  }

  /**
   * Remove the instance from the IntersectionObserver
   * to prevent memory leaks.
   */
  componentWillUnmount() {
    if (this.observer && this.wrapper) {
      // for memory leaks
      this.observer.unobserve(this.wrapper);
    }
  }

  /**
   * Adds the current instance to the IntersectionObserver
   * if created and all React refs exist.
   *
   * Backwards compatible for browsers without support.
   */
  addImageToObserver = () => {
    if (this.observer && this.wrapper) {
      this.observer.observe(this.wrapper);
    } else if (this.imgElm && this.props.src) {
      // for browser compatibility
      this.imgElm.src = this.props.src;
    }
  };

  /**
   * Called when a required React ref is stored on the
   * class instance.
   */
  onRefStored = () => {
    if (this.wrapper && this.imgElm) {
      window.requestAnimationFrame(this.addImageToObserver);
    }
  };

  /**
   * Set afterimage wrapper elm React ref on the instance.
   *
   * Wrapper elm is necessary so that the IntersectionObserver
   * works (needs an element with at least 1px by 1px).
   */
  setWrapperRef = (elm: any) => {
    this.wrapper = ReactDOM.findDOMNode(elm) as any;
    this.onRefStored();
  };

  /**
   * Set img tag elm React ref on the instance.
   */
  setImgRef = (elm: any) => {
    this.imgElm = ReactDOM.findDOMNode(elm) as any;
    this.onRefStored();
  };

  /**
   * Called when the image src has completely finished
   * downloading.
   *
   * Used to prevent displaying the image until it's fully
   * downloaded.
   */
  onImageLoad = () => {
    this.setState({ hasLoaded: true });
    if (this.props.onLoad) {
      this.props.onLoad();
    }
  };

  render() {
    const {
      src,
      className = "",
      aspectHeight = 9,
      aspectWidth = 16,
      withPlaceholder = true,
      ...imgProps
    } = this.props;
    const { hasLoaded } = this.state;
    const aspectRatio = aspectHeight / aspectWidth;

    // data-src is needed so that the cached intersection observer can read the src from the wrapper and set it on the img tag

    return (
      <div
        className={`afterimage ${
          hasLoaded ? "afterimage--loaded" : ""
        } ${className}`}
        ref={this.setWrapperRef}
      >
        <img
          {...imgProps}
          className="afterimage__image"
          onLoad={this.onImageLoad}
          ref={this.setImgRef}
          data-src={src}
          style={{
            width: "100%",
            height: "auto",
            opacity: hasLoaded ? 1 : 0,
            transition: "opacity 300ms ease",
            position: withPlaceholder ? "absolute" : "static",
            left: 0
          }}
        />
        {withPlaceholder && (
          <div
            className="afterimage__placeholder"
            style={{
              width: "100%",
              paddingTop: `${aspectRatio * 100}%`,
              opacity: hasLoaded ? 0 : 1,
              transition: "opacity 300ms ease",
              pointerEvents: "none"
            }}
          />
        )}
      </div>
    );
  }
}

const CACHE_KEY = "__AFTER_IMAGE_INTERSECTION_OBSERVER__";

/**
 * Returns an IntersectionObserver that loads the image
 * when it is at least 10% visible in the viewport.
 *
 * NB: Cached on the window for performance
 */
function getImageLoaderObserver(): null | IntersectionObserver {
  if (typeof IntersectionObserver === "undefined") {
    return null;
  }

  // return the cached observer for performance
  if (typeof window[CACHE_KEY] !== "undefined") {
    return window[CACHE_KEY];
  }

  // create a new observer and cache it on the window
  const threshold = 0.1; // 10% in view
  window[CACHE_KEY] = new IntersectionObserver(
    entries => {
      entries.map(entry => {
        const img = entry.target.querySelector("img");
        if (img && !img.src && entry.intersectionRatio >= threshold) {
          // data-src is read from the wrapperElm so that the intersection observer does not need to read from props and can be cached
          const src = img.getAttribute("data-src");
          if (src) {
            img.src = src;
          }
        }
      });
    },
    {
      threshold
    }
  );

  return window[CACHE_KEY];
}

export default AfterImage;