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.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 machineif (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 imageSrcif (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 spinnerisImageFetching: 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' }}><Imageextend={{ width: '560px', height: 'auto' }}src={src}alt="Field flowers"/></Card></Flex>);
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);}}
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 machineif (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 imageSrcif (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 loadingisImageFetching: 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};}
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 machineif (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 imageSrcif (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 loadingisImageFetching: 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};}