selbekk

How to write a progressive image loading hook

How to write a progressive image loading hook

May 9, 2019
3 min read

Want to display a fallback thumbnail while you download your hero image? Then this article is for you!

While we tune every kilobyte out of our JavaScript bundles, we often forget to optimize our image loading strategies the same way. We might be sitting looking at a blank screen for several seconds before the hero image loads, giving the background to your white text.

This article is going to show you how you can write a hook that handles your progressive image loading for you!

What's progressive image loading?
Progressive image loading - at least in this context - is loading a very low-resolution version of the image first, while loading the high resolution version in the background. Once the high resolution version is loaded, the images are swapped.

We're going to name our hook useProgressiveImage, and pass it an object of a src prop and a fallbackSrc prop. It will return the best available image src already loaded, or null if neither has loaded yet.

function useProgressiveImage({ src, fallbackSrc }) {
  return null;
}

We can pre-load images like this by creating a new Image instance, and setting its src attribute. We can listen to its onload event, and react to it accordingly. Let's write out some of this boilerplate code:

function useProgressiveImage({ src, fallbackSrc }) {
  const mainImage = new Image();
  const fallbackImage = new Image();

  mainImage.onload = () => {}; // Still todo
  fallbackImage.onload = () => {}; // Still todo

  mainImage.src = src;
  fallbackImage.src = fallbackSrc;

  return null;
}

This is going to run on every render though - which is going to trigger a ton of useless network requests. Instead, let's put it inside a useEffect, and only run it when the src or fallbackSrc props change.

function useProgressiveImage({ src, fallbackSrc }) {
  React.useEffect(() => {
    const mainImage = new Image();
    const fallbackImage = new Image();

    mainImage.onload = () => {}; // Still todo
    fallbackImage.onload = () => {}; // Still todo

    mainImage.src = src;
    fallbackImage.src = fallbackSrc;
  }, [src, fallbackSrc]);

  return null;
}

Next, we need to keep track of which image has been loaded. We don't want our fallback image to "override" our main image if that would load first (due to caching or just coincidence), so we need to make sure to implement that.

I'm going to keep track of this state with the React.useReducer hook, which accepts a reducer function. This reducer function accepts the previous state (loaded source), and returns the new state depending on what kind of action we dispatched.

function reducer(currentSrc, action) {
  if (action.type === 'main image loaded') {
    return action.src;
  } 
  if (!currentSrc) {
    return action.src;
  }
  return currentSrc;
}

function useProgressiveImage({ src, fallbackSrc }) {
  const [currentSrc, dispatch] = React.useReducer(reducer, null);
  React.useEffect(() => {
    const mainImage = new Image();
    const fallbackImage = new Image();

    mainImage.onload = () => {
      dispatch({ type: 'main image loaded', src });
    };
    fallbackImage.onload = () => {
      dispatch({ type: 'fallback image loaded', src: fallbackSrc });
    };

    mainImage.src = src;
    fallbackImage.src = fallbackSrc;
  }, [src, fallbackSrc]);

  return currentSrc;
}

We've implemented two types of actions here - when the main image is loaded and when the fallback image is loaded. We leave the business logic to our reducer, which decides when to update the source and when to leave it be.

What's with the action types?
If you're like me, you're used to reading action types in CONSTANT_CASE or at the very least camelCase. Turns out, however, you can call them exactly what you want. I was feeling playful here, and just wrote out the intent. Because why not? 😅Since they're only internal to this little hook anyways, it truly doesn't matter much anyhow.

Using our hook is pretty straight forward too.

const HeroImage = props => {
  const src = useProgressiveImage({ 
    src: props.src,
    fallbackSrc: props.fallbackSrc 
  });
  if (!src) return null;
  return <img className="hero" alt={props.alt} src={src} />;
};

I've created a CodeSandbox you can check out and play with if you want!

All rights reserved © 2024