loading...
Cover image for How to write a progressive image loading hook

How to write a progressive image loading hook

selbekk profile image selbekk Originally published at selbekk.io Updated on ・3 min read

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!

Thanks for reading my little mini-article! I always appreciate a share or like or comment, to let me know whether I should keep these coming or not.

Until next time!

Discussion

pic
Editor guide
Collapse
giboork profile image
Lukas

Nice article, but would be way better if it wouldnt show fallback if main image is cached

Collapse
selbekk profile image
selbekk Author

I was pretty sure it’s working that way now?

Collapse
codymurphyjones profile image
codymurphyjones

I am fairly certain it is as well, as long as you are handling the image load properly, servers and browsers will handle that issue.

Collapse
vladsez profile image
Vlad

I really liked ur article, but i am struggling to make it work with array of images, could you please take a look at my codesandbox? codesandbox.io/s/image-fallback-cvvbf Do you have any suggestions?

Collapse
selbekk profile image
selbekk Author

Hi!

Looks like you're using a the useProgressiveImage hook inside of a loop, which violates the rules of hooks.

Instead, create a new component ProgressiveImage, which calls this hook for each image. It'll look like this:

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

const Images = ({ images }) => {
  return (
    <div>
      {images.map(image => <ProgressiveImage key={image} src={image.src} fallbackSrc={image.fallbackSrc} />)}
    </div>
  );
} 
Collapse
vladsez profile image
Vlad

it worked, thank you:)