The Image Loader Hook Demo

Code and examples to "build your own"

Demo site for the different states handled by the Image Loader Hook. It takes into account different uses cases and things to keep in mind. You can navigate all the examples in the top menu or go through the source code of the hook below.

We will start with the first version, which we call simple. Then we will introduce a timeout in case loading the image takes too long. Finally, we will fix an edge case in the strict time version.

Simple

Demo

At is core the hooks logic is simple: when executed we create a Image DOM object and set its src and/or srcset with arguments imageSrc and imageSrcSet respectively. This will trigger the image download handled by the browser. We can't control the downloading process, all is handled by the browsers. We can just listen to events: onerror, onload and onloadstart. When the image has finished downloading, the Image object will trigger the onload event.

In regards to the React specify code, here are some implementation notes:

  • useState can be used as well in case useReducer seems overkill. I used it because it started by setting the flags in the reducer and returning the state.

  • The advantage of using useReducer is that then you can derive new state based on old one. Even if this can be done with useState, one ends creating a setter function which at the end is a reducer.

  • onloadstart event will not work in chrome.

  • isImageFetching will always start as true. This makes it convenient for showing the a loading spinner when server side rendering. Plus, as stated above, we can't detect across browsers when the loading of the image starts.

Here is the code implementation of the simple version.

import { useEffect, useReducer } from 'react';
const IMAGE_LOADED = 'IMAGE_LOADED';
const IMAGE_ERROR = 'IMAGE_ERROR';
const IMAGE_FETCHING = 'IMAGE_FETCHING';
const IMAGE_IDLE = 'IMAGE_IDLE';
const initialState = {
status: IMAGE_IDLE,
};
function reducer(state, action) {
// we use that type as status to create a simple state machine
if (action.type) {
return {
...state,
status: action.type,
};
}
return state;
}
/**
* @param imageSrc The image src
* @param imageSrcSet {string} defines the set of images we will allow the
* browser to choose between, and what size each image is
* @param imagSizes defines a set of media conditions (e.g. screen widths) and
* indicates what image size would be best to choose, when certain media
* conditions are true
* @returns {{isImageFetching: boolean, isImageError: boolean}}
*/
export function useTheImageLoader({
imageSrc,
imageSrcSet = '',
imgSizes = '',
}) {
const [state, dispatch] = useReducer(reducer, initialState);
const { status } = state;
useEffect(() => {
const image = new Image();
// onloadstart will not work on chrome
// https://bugs.chromium.org/p/chromium/issues/detail?id=458851#c12
// image.onloadstart = () => {
// dispatch({ type: IMAGE_FETCHING });
// };
image.onload = () => {
dispatch({ type: IMAGE_LOADED });
};
image.onerror = () => {
dispatch({ type: IMAGE_ERROR });
};
// The image will start downloading when we set the `src` attribute
// with the imageSrc
if (imageSrc) {
image.src = imageSrc;
image.srcset = imageSrcSet;
image.sizes = imgSizes;
}
}, [dispatch, imageSrc, imageSrcSet, imgSizes]);
return {
// To be compatible with SSR pages, idle and fetching status will show
// the spinner
isImageFetching: status === IMAGE_IDLE || status === IMAGE_FETCHING,
isImageError: status === IMAGE_ERROR,
// If we wanted we could expose the idle status, however
// given that we don't have a predictable way of detecting when image
// fetching starts we comment it out and live it as reference
// isIdle: status === IMAGE_IDLE
};
}

The above code works fine, however we might want to have control when to stop the spinner and not just leave it to the browsers inner workings. If that is the case, then we can add a timeout as shown in the next section. The spinner will not be necessary in case you want to show a placeholder (see placeholder Demo) In that case, it will acceptable to have the placeholder and you can do something like this:

/...
const placeholderImg = '/image-placeholder.png';
const { isImageFetching } = useTheImageLoader({ imageSrc });
const src = isImageFetching ? placeholderImg : imageSrc;
return (
<Flex>
<Card extend={{ maxWidth: '560px' }}>
<Image
extend={{ width: '560px', height: 'auto' }}
src={src}
alt="Field flowers"
/>
</Card>
</Flex>
);

Timeout

Demo

It is kind of straight forward to introduce a timeout in the loader. As shown below, if the timeout param is set, then we use setTimeout to enable the timeout logic. If it triggers then it will dispatch a action that sets the status to IMAGE_TIMEOUT.

...
if (imageSrc) {
image.src = imageSrc;
image.srcset = imageSrcSet;
image.sizes = imgSizes;
if (timeout) {
setTimeout(() => {
dispatch({ type: IMAGE_TIMEOUT });
}, timeout);
}
}
...

There is only one caveat with this approach: the browser could manage to load the image after the timeout is triggered. So, any notification or change in the UI made to visualize the timeout will not make sense to the user.

To fix this bug, we need to make the browser abort downloading of the image. Currently the best way to do that is to re-assign to the image src a transparent gif.

if (imageSrc) {
image.src = imageSrc;
image.srcset = imageSrcSet;
image.sizes = imgSizes;
if (timeout) {
setTimeout(() => {
// When our timeout is triggered, then we need to abort/cancel the download
// Otherwise we will show the image after the timeout. Message is shown.
image.src = TRANSPARENT_GIF;
image.srcset = TRANSPARENT_GIF;
dispatch({ type: IMAGE_TIMEOUT });
}, timeout);
}
}

With Timeout

import { useEffect, useReducer } from 'react';
const IMAGE_LOADED = 'IMAGE_LOADED';
const IMAGE_ERROR = 'IMAGE_ERROR';
const IMAGE_FETCHING = 'IMAGE_FETCHING';
const IMAGE_IDLE = 'IMAGE_IDLE';
const IMAGE_TIMEOUT = 'IMAGE_TIMEOUT';
const initialState = {
status: IMAGE_IDLE,
};
function reducer(state, action) {
// we use that type as status to create a simple state machine
if (action.type) {
return {
...state,
status: action.type,
};
}
return state;
}
/**
* @param imageSrc The image src
* @param imageSrcSet {string} defines the set of images we will allow the
* browser to choose between, and what size each image is
* @param imagSizes defines a set of media conditions (e.g. screen widths) and
* indicates what image size would be best to choose, when certain media
* conditions are true
* @param timeout number value in milliseconds stating when timeout state should
* trigger.
* @returns
* {{isImageFetching: boolean, isImageError: boolean, isImageTimeout: boolean}}
*/
export function useTheImageLoader({
imageSrc,
imageSrcSet = '',
imgSizes = '',
timeout,
}) {
const [state, dispatch] = useReducer(reducer, initialState);
const { status } = state;
useEffect(() => {
const image = new Image();
// onloadstart will not work on chrome
// https://bugs.chromium.org/p/chromium/issues/detail?id=458851#c12
// image.onloadstart = () => {
// dispatch({ type: IMAGE_FETCHING });
// };
image.onload = () => {
clearTimeout(timerId.current);
dispatch({ type: IMAGE_LOADED });
};
image.onerror = () => {
clearTimeout(timerId.current);
dispatch({ type: IMAGE_ERROR });
};
// The image will start downloading when we set the `src` attribute
// with the imageSrc
if (imageSrc) {
image.src = imageSrc;
image.srcset = imageSrcSet;
image.sizes = imgSizes;
if (timeout) {
timerId.current = setTimeout(() => {
dispatch({ type: IMAGE_TIMEOUT });
}, timeout);
}
}
}, [dispatch, imageSrc, imageSrcSet, imgSizes, timeout, timerId]);
return {
// To be compatible with SSR pages, we have on its start
// state as loading
isImageFetching: status === IMAGE_IDLE || status === IMAGE_FETCHING,
isImageError: status === IMAGE_ERROR,
isImageTimeout: status === IMAGE_TIMEOUT,
// If we wanted we could expose the idle status, however
// given that we don't have a predictable way of detecting when image
// fetching starts we comment it out and live it as reference
// isIdle: status === IMAGE_IDLE
};
}

With "Strict" Timeout (Canceling downloading of image)

import { useEffect, useReducer } from 'react';
const IMAGE_LOADED = 'IMAGE_LOADED';
const IMAGE_ERROR = 'IMAGE_ERROR';
const IMAGE_FETCHING = 'IMAGE_FETCHING';
const IMAGE_IDLE = 'IMAGE_IDLE';
const IMAGE_TIMEOUT = 'IMAGE_TIMEOUT';
const initialState = {
status: IMAGE_IDLE,
};
function reducer(state, action) {
// we use that type as status to create a simple state machine
if (action.type) {
if (state.status === IMAGE_TIMEOUT || state.status === IMAGE_ERROR) {
return state;
}
return {
...state,
status: action.type,
};
}
return state;
}
/**
* @param imageSrc The image src
* @param imageSrcSet {string} defines the set of images we will allow the
* browser to choose between, and what size each image is
* @param imagSizes defines a set of media conditions (e.g. screen widths) and
* indicates what image size would be best to choose, when certain media
* conditions are true
* @param timeout number value in milliseconds stating when timeout state should
* trigger.
* @returns
* {{isImageFetching: boolean, isImageError: boolean, isImageTimeout: boolean}}
*/
export function useTheImageLoader({
imageSrc,
imageSrcSet = '',
imgSizes = '',
timeout,
}) {
const [state, dispatch] = useReducer(reducer, initialState);
const { status } = state;
useEffect(() => {
const image = new Image();
// onloadstart will not work on chrome
// https://bugs.chromium.org/p/chromium/issues/detail?id=458851#c12
// image.onloadstart = () => {
// dispatch({ type: IMAGE_FETCHING });
// };
image.onload = () => {
clearTimeout(timerId.current);
dispatch({ type: IMAGE_LOADED });
};
image.onerror = () => {
clearTimeout(timerId.current);
dispatch({ type: IMAGE_ERROR });
};
// The image will start downloading when we set the `src` attribute
// with the imageSrc
if (imageSrc) {
image.src = imageSrc;
image.srcset = imageSrcSet;
image.sizes = imgSizes;
if (timeout) {
timerId.current = setTimeout(() => {
// When our timeout is triggered, then we need to abort/cancel the download
// Otherwise we will show the image after the timeout. Message is shown.
image.src = TRANSPARENT_GIF;
image.srcset = TRANSPARENT_GIF;
dispatch({ type: IMAGE_TIMEOUT });
}, timeout);
}
}
}, [dispatch, imageSrc, imageSrcSet, imgSizes, timeout, timerId]);
return {
// To be compatible with SSR pages, we have on its start
// state as loading
isImageFetching: status === IMAGE_IDLE || status === IMAGE_FETCHING,
isImageError: status === IMAGE_ERROR,
isImageTimeout: status === IMAGE_TIMEOUT,
// If we wanted we could expose the idle status, however
// given that we don't have a predictable way of detecting when image
// fetching starts we comment it out and live it as reference
// isIdle: status === IMAGE_IDLE
};
}