17
Extending Next.js' <Image /> component to improve UX
If you have worked on Next.js, it's a good chance that you might have ended up using the Image component.
While the Next's Image component already has a lot of built in features like blurring placeholder images when the images are loading or controlling image quality to improve the UX.
In this article, we explore extending the Next's image component to improve the end user experience alternatively.
Here we address 2 main states when serving images
It is a sort of best practice to lazy load below the fold images on fast websites. Lazy loading images contributes to better UX since it helps in reducing the load time among other things, however, to improve it one step further we add something like an intermediate form of display until the image loads. For example, spinner or a skeleton loader
What happens if the image url is incorrect or if the image service API is down for some reason? It would be ideal to have a fallback image so the end user has a seamless experience and doesn't end up seeing something like this.
When using Next.js' Image component, it is important to wire it up with a fallback image because of domains.
After we've taken care of these 2 states, the solution ends up looking like this:
Let's dive in and extend the Next.js' component to further support the aforementioned states.
Starting with the main imports
import React from 'react'
import Image, { ImageProps } from 'next/image'
Now, we create a React component that simply extends the Next's Image component & also the types
type ImageWithStateProps = ImageProps
function ImageWithState (props: ImageWithStateProps) {
return <Image {...props} />
}
So far, we've done nothing other than adding a transparent abstraction over the Image component. The component ImageWithState will work same as Next's Image, only that the component name is different.
Let's now introduce the states
function ImageWithState (props: ImageWithStateProps) {
const [loading, setLoading] = React.useState(true)
const [onErrorSrc, setOnErrorSrc] = React.useState<string | undefined>(undefined)
return <Image {...props} />
}
When the component mounts, loading
is set to true by default as the image would start loading
The onErrorSrc prop is the source url for the fallback
image. The fallback image appears when the Image component throws an error. Let's go ahead and create the function to handle the errored state
function handleOnError (e: React.SyntheticEvent<HTMLImageElement, Event>): void {
e?.currentTarget?.src !== props.fallback && setOnErrorSrc(props.fallback)
}
This is triggered by the onError event
return <Image {...props} onError={(e) => handleOnError(e)} />
The handleOnError function is called when the component would error. In that case we change the src
prop of the element to the fallback image.
Now, we manage the loading state
return (
<div style={{ position: "relative" }}>
{loading === true && (
<SkeletonLoader
style={{
position: "absolute",
zIndex: props.debug === "true" ? 99 : "auto"
}}
height={props.height}
width={props.width}
/>
)}
<Image
{...props}
src={onErrorSrc || src}
onLoadingComplete={() => !props.debug && setLoading(false)}
onError={(e) => handleOnError(e)}
/>
</div>
);
To represent the loading state, I have used SkeletonLoader component. Feel free to use any other loading indicators like spinners or splash based on your use case.
Furthermore, there is a debug
prop which can be helpful when developing & styling to check if the loading indicators are styled appropriately.
Mostly images are served from disk cache, in that case it becomes difficult to replicate a "loading" state for an image while developing. In such a situation, enabling the debug prop would provide a much efficient dev workflow than network throttling via the browser's dev tools.
If you haven't noticed yet, we pass the same height
and width
prop to the skeleton loader. This also further helps in avoiding layout shift as the space will be preserved for the image.
Finally, updating the type
type ImageWithStateProps = ImageProps & {
fallback: string;
debug?: string;
};
Using the wrapper component should be same as using the Next's Image component.
The ImageWithState component added 2 extra props which are for the fallback image in case of an error & a debug prop to help us make sure that the skeleton loader displays appropriately
Feel free to fork or play around this component on CodeSandbox
You can also visit https://f1icr.csb.app/ to check out the working solution
Build size: These changes, including adding the svg skeleton loader library which is react-skeleton-loader
adds approximately 5kB to your production build. But keep in mind that this component is reusable across your entire app so the build size won't bloat up further
Web vitals: Next's Image component loads the images lazily by default. If you're loading the images above the fold, remember to pass the priority prop so the page doesn't lose out on LCP points
Blur: It's possible to display a blurred image placeholder when the image is being loaded. This is supported by the placeholder
property on the Image component.
Unoptimized: You may have seen an unoptimized
true prop passed to the Image component. This is because I haven't setup any loader configuration for these images. Make sure to keep your images optimised and pass srcSet
, (now device sizes in Next.js) & responsive images in modern image formats!
Presently, Next.js' Image component only supports displaying a blurred
placeholder when an image is being loaded, something like what you may have seen on medium.com.
As one solution doesn't fit all use cases, this write-up represents an alternate way to wire up the Image component while still keeping the end user experience in mind.
17