loading...
Cover image for Create a Lazy-Loading Image Component with React Hooks

Create a Lazy-Loading Image Component with React Hooks

hangindev profile image Jason Leung πŸ§—β€β™‚οΈπŸ‘¨β€πŸ’» Updated on ・4 min read

Lazy-loading images (like those in Medium or those created by gatsby-image 🧑) can sometimes add an extra touch of style to a page. To create such an effect, one will need A) a tiny version of the image for preview, ideally inlined as data URL, and B) the aspect ratio of the image to create a placeholder to prevent reflows. In this article, I will share how I created a lazy-loading image component with React Hooks.

CodeSandbox Demo

Hi, Dev.to Developers! Excited to be part of the DEV Community and this is my first post.

First, the barebone - HTML/CSS 🦴

Usually, a lazing loading image consists of 4 HTML Elements:

<div class="wrapper">
  <div style="padding-bottom:76%;"></div> 
  <img
    src="https://images.unsplash.com/photo-1518991791750-044b923256f0?fit=crop&w=25"
  />
  <img
    src="https://images.unsplash.com/photo-1518991791750-044b923256f0?fit=crop&w=1200"
    class="source"
  />
</div>
  1. a relatively positioned wrapper div,
  2. an intrinsic placeholder div for maintaining aspect ratio. It has padding-bottom with a percentage value(relative to the width of the containing block), e.g. for a 16:9 image, the percentage is calculated as 9/16 * 100% = 56.25%,
  3. an absolutely positioned img for the tiny version of the image, also known as LQIP(Low-Quality Image Placeholder), stretched to cover the wrapper. Data URL is usually used as the src to save HTTP requests,
  4. an absolutely positioned img for the source image, placed on top of the LQIP, initialized with opacity: 0.
.wrapper {
  position: relative;
  overflow: hidden;
}
img {
  position: absolute;
  width: 100%;
  height: 100%;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  object-fit: cover;
  object-position: center;
}
.source {
  opacity: 0;
  transition: opacity 1s;
}
.loaded {
  opacity: 1;
}

Turn it into React Component βš›

import React, { useState, useEffect, useRef } from "react";
import clsx from "clsx"; // a utility for constructing className conditionally
function LazyImage({ className, src, alt, lqip, aspectRatio = 2/3 }) {
  const [loaded, setLoaded] = useState(false);
  const imgRef = useRef();
  useEffect(() => {
    if (imgRef.current && imgRef.current.complete) {
      setLoaded(true);
    }
  }, []);
  return (
    <div className={clsx("wrapper", className)}>
      <div style={{ paddingBottom: `${100 / aspectRatio}%` }} />
      <img src={lqip} aria-hidden="true" />
      <img
        loading="lazy"
        src={src}
        alt={alt}
        ref={imgRef}
        onLoad={() => setLoaded(true)}
        className={clsx("source", loaded && "loaded")}
      />
    </div>
  );
}
export default LazyImage;

Let's break it down: there is a loaded state to track the loading state of the souce image, initialized to be false. A "load" event listener is added to the source img element so when it finishes loading, the state is updated and a "loaded" class name is appended to its class list which sets its opacity to 1. In cases which the source image has completely loaded before this component is mounted, the newly added "load" event listener will never fire. That's why a ref is also passed to the img element for checking its complete attribute on mount, and update the state accordingly.

Also, a loading="lazy" attribute is added to the source img to tell the browser to load the image immediately if it is in the viewport, or to fetch it when the user scrolls near it. More about that in this web.dev article. I also added aria-hidden="true" to the LQIP img to hide it from the accessibility API.

Usage

To use this component, you'll have to generate the image LQIP and get its aspect ratio. There are libraries that help you to integrate the generation into your build process, for example, zouhir/lqip. Apparently, if you're using Cloudindary, you can create LQIP through their image transformation pipeline. But I suspect you can only get a regular URL instead of data URL or base64 so you might have to convert it yourself if you want to inline it.

In previous projects, I used sharp(a high-performance image processing module) in Next.js getStaticProps (a function that runs at build time for static generation) to help me populating those image data. Below is the function that I used:

import got from 'got'; // HTTP request library for Node.js
import sharp from 'sharp';
sharp.cache(false);
async function generateLazyImage(src) {
  const { body } = await got(src, { responseType: 'buffer' });
  const sharpImage = sharp(body);
  const { width, height, format } = await sharpImage.metadata();
  const lqipBuf = await sharpImage
    .resize({ width: 30, height: 30, fit: 'inside' })
    .toBuffer();
  return {
    src,
    aspectRatio: width / height,
    lqip: `data:image/${format};base64,${lqipBuf.toString('base64')}`,
  };
}

That's it! This <LazyImage /> is a pretty simple component that I use in almost all of my projects. Let me know your thoughts and how you present images on your sites. πŸ˜‰

Please follow my Twitter account if you want to read my future posts. I promise I will figure out how to do RSS with Next.js soon...πŸ”œ

All my writings are posted on my personal blog hangindev.com.

I am redesigning my blog to integrate a CMS and I figured it's another good chance to learn new tech stacks. After playing with several headless CMSs, I picked Sanity.io to be used with my go-to SSG/SSR framework Next.js. I also tried out TailwindCSS to see what's going on with this trendy utility-first CSS. I will share my learning progress and tips I found regularly.

Discussion

markdown guide
 

Ah, how awesome! Thank you for this post β€” I need to implement this in a small project I built to help students understand lifecycle methods.

 

Thanks! Wow, that's my first time to see such an analogy. Interesting!πŸ˜†