DEV Community

Cover image for Create a Lazy-Loading Image Component with React Hooks
Jason Leung ๐Ÿง—โ€โ™‚๏ธ๐Ÿ‘จโ€๐Ÿ’ป
Jason Leung ๐Ÿง—โ€โ™‚๏ธ๐Ÿ‘จโ€๐Ÿ’ป

Posted on • Edited on • Originally published at hangindev.com

Create a Lazy-Loading Image Component with React Hooks

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... (Updated on Jun25, 2020: there is RSS feed for my blog now. โœ…)

React Summit

React Summit is coming back on October 15-16. There'll be speakers like Kent C. Dodds, Max Stoiber, Sara Vieira, Sharif Shameem.

Register for free before September 20: https://ti.to/gitnation/react-summit?source=REFURCR-1.

Top comments (2)

Collapse
 
sylwiavargas profile image
Sylwia Vargas

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.

Collapse
 
hangindev profile image
Jason Leung ๐Ÿง—โ€โ™‚๏ธ๐Ÿ‘จโ€๐Ÿ’ป

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