next 14 Showing image fallback on public folder image 404

I am using next 14, and storing some images in my app's public folder. These images are then requested by <img> elements in some of my client components. If the requested resource does not exist, I would like to show a 'default image' which is effectively a file that is guarnateed to exist in the public folder. To try and do this I have written a simple client component that wraps the img element.
"use client";

import { useEffect, useState } from "react";

type Props = React.ImgHTMLAttributes<HTMLImageElement> & {
src: string;
fallbackSrc?: string;
};

export default function ImageWithFallback({
fallbackSrc,
src,
...props
}: Props) {
const [hasErrored, setHasErrored] = useState<boolean>(false);
const [activeSrc, setActiveSrc] = useState<string>(src);

useEffect(() => {
console.log(hasErrored);
if (hasErrored && fallbackSrc !== undefined) setActiveSrc(fallbackSrc);
}, [hasErrored]);

return (
<img
{...props}
src={activeSrc}
onError={(e) => {
setHasErrored(true);
}}
/>
);
}
"use client";

import { useEffect, useState } from "react";

type Props = React.ImgHTMLAttributes<HTMLImageElement> & {
src: string;
fallbackSrc?: string;
};

export default function ImageWithFallback({
fallbackSrc,
src,
...props
}: Props) {
const [hasErrored, setHasErrored] = useState<boolean>(false);
const [activeSrc, setActiveSrc] = useState<string>(src);

useEffect(() => {
console.log(hasErrored);
if (hasErrored && fallbackSrc !== undefined) setActiveSrc(fallbackSrc);
}, [hasErrored]);

return (
<img
{...props}
src={activeSrc}
onError={(e) => {
setHasErrored(true);
}}
/>
);
}
However, the 'onError' callback is never fired. I believe this is because althought Next is in fact responding with 404 it is also sending a response body. (the default not-found HTML). I have attached images showing that when the image content is requested, as stated the server responds with a 404, however also sends content in the response body. I wonder if because there is techincally a 'response' the image element is trying to display the response body content (the HTML) as the content of the image, and thus the onError callback does not fire. Can anyone think of an alternative approach / fix to present a different image on image load fails from the public folder. Perhaps a different callback attribute on the img element could be leveraged and we could somehow check if the response was a 200? (or atleast not 404) ?
No description
No description
10 Replies
michaeldrotar
michaeldrotar7mo ago
You need to guarantee that onerror is set before src is set. Check out the useEffect I added here that properly shows the error: https://stackblitz.com/edit/nextjs-ygikmj?file=app%2FMyImage.tsx,app%2Fpage.tsx
SG.dev
SG.dev7mo ago
I mean you could just do src={activeSrc || "fallbackImg.png"}
kal
kalOP7mo ago
I made some slight changes as it didn't seem to work for me without them. However, this general approach definitely does seem to work.
"use client";

import { useEffect, useState } from "react";

type Props = React.ImgHTMLAttributes<HTMLImageElement> & {
src: string;
fallbackSrc?: string;
};

export default function ImageWithFallback({
fallbackSrc,
src,
...props
}: Props) {
const [hasErrored, setHasErrored] = useState<boolean>(false);
const [activeSrc, setActiveSrc] = useState<string>(src);

useEffect(() => {
const img = new Image();
img.onerror = () => {
setHasErrored(true);
};
img.src = activeSrc;
}, []);

useEffect(() => {
if (hasErrored && fallbackSrc !== undefined) setActiveSrc(fallbackSrc);
}, [hasErrored]);

return <img {...props} src={activeSrc} />;
}
"use client";

import { useEffect, useState } from "react";

type Props = React.ImgHTMLAttributes<HTMLImageElement> & {
src: string;
fallbackSrc?: string;
};

export default function ImageWithFallback({
fallbackSrc,
src,
...props
}: Props) {
const [hasErrored, setHasErrored] = useState<boolean>(false);
const [activeSrc, setActiveSrc] = useState<string>(src);

useEffect(() => {
const img = new Image();
img.onerror = () => {
setHasErrored(true);
};
img.src = activeSrc;
}, []);

useEffect(() => {
if (hasErrored && fallbackSrc !== undefined) setActiveSrc(fallbackSrc);
}, [hasErrored]);

return <img {...props} src={activeSrc} />;
}
However, it is worth noting that if the original image (non-fallback) src is not present, the client seems to make 2 requests which will both fail to the server - ideally we would only make one. However, I'm not sure if this is a caveat that only occurs during strict mode. Also - could you explain some more about what the new Image() contructor does? It seems to me that it initialises a new HTML dom element, however do we even use the element if this is the case? It doesn't seem we reference the const again outside of the useEffect callback. If it is a new dom element could we somehow save it in state so the .src call is only made once? This wouldn't work. The question is such that if the resource located at the path defined by activeSrc doesn't exist then resort to the fallback image. Hence, even if activeSrc is defined and is truthy, the resource specified by the string provided might not exist. This is the case we want to handle.
michaeldrotar
michaeldrotar7mo ago
it is a new <img />, yes - this is a common approach from long before React was ever around so that the user doesn't see what's going on if you're seeing two requests, only the first should actually be made and any additional ones should be coming from your disk cache you could also tweak this to use only a single <img /> via a ref and destructuring the props better so that you're directly assigning onError and src through the ref and jsx only assigns the other ones, but then I'd also do something to hide the image until the end so it doesn't flash the broken state
kal
kalOP7mo ago
How would you reccomended potentially implemented such a fix in regards to not flashing the broken state? Also what do you mean by ‘destructuring the props better’ ?
michaeldrotar
michaeldrotar7mo ago
To not flash the broken state I'd use visibility: hidden until you have a working image in there - this way if a height/width are given then you avoid the layout shift that comes with other options (using the NextJS <Image /> component also helps here and might be a better abstraction than <img />) Sorry "better" wasn't a good word choice. I was skimming over the code and there are a number of things that could improve code clarity and functionality: - {...props} looks like all props but it's not, I'd name this restProps since you're already taking src and fallbackSrc out of what would be the full props (this is where I thought you weren't destructuring at all when I first looked) - I'd destructure onError as well and call it if given, or exclude it from the type (I'm assuming it's part of ImgHTMLAttributes<HTMLImageElement> - once you are setting visibility: hidden while it loads, you'll need to ensure you still respect any given props.styles - you could also use a single useEffect, having one effect to set a variable to trigger another one delays things for an entire render cycle and uses 2 renders to do what a single one could do - you're not currently handling if src or fallbackSrc change
SG.dev
SG.dev7mo ago
Apologies, I misunderstood your question and thought perhaps we were over complicating things.
kal
kalOP7mo ago
no worries 👍 I like the visibility hidden suggestion, thanks. However I am not sure how you suggest we could 'use only a single <img /> via a ref'. I asumme you mean to use the useRef hook like so const imageRef = useRef<HTMLImageElement>(null); and then bind it to the returned img. Would you then suggest somehow mutating the imageRef.current to use the desired onError callbacks? If this is the case, does this not also fall into the pitfall that the onError callback is only bound after the image has already tried to fetch the image source? i've come up with this updated component:
"use client";

import { useEffect, useRef, useState } from "react";

type Props = Omit<React.ImgHTMLAttributes<HTMLImageElement>, "onError"> & {
src: string;
fallbackSrc?: string;
};

export default function ImageWithFallback({
fallbackSrc,
src,
...restProps
}: Props) {
const [activeSrc, setActiveSrc] = useState<string | undefined>(src);
const [hasLoaded, setHasLoaded] = useState<boolean>(false);

useEffect(() => {
const dummy = new Image();

dummy.onerror = () => {
if (fallbackSrc) setActiveSrc(fallbackSrc);
};

dummy.onload = () => {
setHasLoaded(true);
};

dummy.src = src;
}, [src]);

useEffect(() => {
if (activeSrc && activeSrc === src) return;

setActiveSrc(fallbackSrc);
}, [fallbackSrc]);

return (
<img
{...restProps}
style={{
visibility:
!hasLoaded && activeSrc !== fallbackSrc
? "hidden"
: restProps.style?.visibility,
...restProps.style,
}}
src={activeSrc}
onLoad={() => {
setHasLoaded(true);
}}
/>
);
}
"use client";

import { useEffect, useRef, useState } from "react";

type Props = Omit<React.ImgHTMLAttributes<HTMLImageElement>, "onError"> & {
src: string;
fallbackSrc?: string;
};

export default function ImageWithFallback({
fallbackSrc,
src,
...restProps
}: Props) {
const [activeSrc, setActiveSrc] = useState<string | undefined>(src);
const [hasLoaded, setHasLoaded] = useState<boolean>(false);

useEffect(() => {
const dummy = new Image();

dummy.onerror = () => {
if (fallbackSrc) setActiveSrc(fallbackSrc);
};

dummy.onload = () => {
setHasLoaded(true);
};

dummy.src = src;
}, [src]);

useEffect(() => {
if (activeSrc && activeSrc === src) return;

setActiveSrc(fallbackSrc);
}, [fallbackSrc]);

return (
<img
{...restProps}
style={{
visibility:
!hasLoaded && activeSrc !== fallbackSrc
? "hidden"
: restProps.style?.visibility,
...restProps.style,
}}
src={activeSrc}
onLoad={() => {
setHasLoaded(true);
}}
/>
);
}
I believe it makes use of most of the suggested improvements you provided. However - I'm not sure I've implemented the ref part you suggested. Also, I am currently omitting the onError prop as it expected an event argument. I would rather like to not omit this and instead call the provided callback in addition to the component required behaviour, however when I attempted to give dummy.onerror an argument e typescript didn't seem to know what type this argument would be. I wonder if this is because I am redefining the callback and if I should specify that e is of type image event (or whatever the correct type is).
michaeldrotar
michaeldrotar7mo ago
getting there, couple issues I see: - nothing updates activeSrc when src changes - nothing sets hasLoaded back to false if it needs to load something new - ...restProps.style should be listed first so your override wins I'm not sure how to better explain what I meant by using a ref without showing the code so I updated my demo. - I added a small test bed to page.tsx so I can play with it. - Mine updates when props change - but the visibility flash on a cached image from handling the props updating is pretty noticeable in a side-by-side comparison. It'd need more testing. Maybe it's fine in a real world scenario. Maybe something like requestAnimationFrame(() => if (status === 'loading') visibility = 'hidden') to delay hiding it long enough to check if it will load immediately or not might help. Maybe it's not necessary at all (I was thinking to prevent seeing a "broken image" but in testing so far I'm not seeing one regardless - maybe onerror swapping the img.src prevents it from flashing, at least in Chrome.) - And I also hit the TS error on the event object, I didn't spend time figuring it out. - But you can see how to use the ref to do everything without the dummy img element if nothing else. https://stackblitz.com/edit/nextjs-ygikmj?file=app%2FMyImage.tsx,app%2FImageWithFallback.tsx,app%2Fpage.tsx
kal
kalOP7mo ago
Thank you once again. I see what you mean now with using the ref, I was confused as in my head i was adiment with using the activeSrc state and passing that to the react element as the src prop - hence I thought we'd just encounter the original issue of onError firing after src starts loading. In regards to the event typing mismatch I've found that apparently React wraps native events in a wrapper so they behave the same way in all browsers https://legacy.reactjs.org/docs/events.html . I'm not entirely sure if there is a way to convert from a DOM event to this wrapper, (https://stackoverflow.com/questions/65889954/how-to-convert-a-raw-dom-event-to-a-react-syntheticevent) hence I think for now I'm just going to omit the onLoad and onError props from the component. In regards to the image flashing, again, It is not ideal - however, I think it is bearable. I am also not familiar with requestAnimationFrame so I wouldn't be able to do much using this right now. I also can't think of any other means of solving the issue.
SyntheticEvent – React
A JavaScript library for building user interfaces
Stack Overflow
How to convert a "raw" DOM Event to a React SyntheticEvent?
I use two libraries, one library which emits "raw" DOM events (lib.dom.d.ts), and one other library which consumes React.SyntheticEvents. What is the best way to cleanly transform the raw...
Want results from more Discord servers?
Add your server