Tracks whether the image inside a wrapper is loading, loaded or failed, whatever component drew it, so a card or a gallery can swap a broken image for a fallback.

pnpm add @behivetech/hooks.use-image-status

registry/behivetech/hooks/use-image-status

use-image-status.spec.ts
1import { createElement } from "react";
2import { fireEvent, render, screen } from "@testing-library/react";
3import { beforeAll, describe, expect, it } from "vitest";
4import { useImageStatus } from "./use-image-status.js";
5
6const Probe = ({ src, alt = "" }: { src: string; alt?: string }) => {
7 const { ref, status, alt: failedAlt, handlers } = useImageStatus(src);
8
9 return createElement(
10 "div",
11 { ref, ...handlers },
12 createElement("img", { src, alt }),
13 createElement("output", { "data-testid": "status" }, status),
14 createElement("output", { "data-testid": "alt" }, failedAlt),
15 );
16};
17
18// jsdom never loads images, so `complete` and `naturalWidth` are stubbed: an
19// image has settled only when its src says so.
20beforeAll(() => {
21 Object.defineProperty(HTMLImageElement.prototype, "complete", {
22 configurable: true,
23 get(this: HTMLImageElement) {
24 return /\/(broken|done)\.jpg$/.test(this.src);
25 },
26 });
27 Object.defineProperty(HTMLImageElement.prototype, "naturalWidth", {
28 configurable: true,
29 get(this: HTMLImageElement) {
30 return this.src.endsWith("/done.jpg") ? 10 : 0;
31 },
32 });
33});
34
35const getStatus = () => screen.getByTestId("status").textContent;
36
37describe("useImageStatus", () => {
38 it("starts at loading", () => {
39 render(createElement(Probe, { src: "/pending.jpg" }));
40 expect(getStatus()).toBe("loading");
41 });
42
43 it("reports loaded when the image fires load", () => {
44 render(createElement(Probe, { src: "/pending.jpg" }));
45 fireEvent.load(screen.getByRole("presentation"));
46 expect(getStatus()).toBe("loaded");
47 });
48
49 it("reports error and keeps the alt text when the image fires error", () => {
50 render(createElement(Probe, { src: "/pending.jpg", alt: "A porch" }));
51 fireEvent.error(screen.getByAltText("A porch"));
52 expect(getStatus()).toBe("error");
53 expect(screen.getByTestId("alt").textContent).toBe("A porch");
54 });
55
56 it("ignores load and error from an element that is not an image", () => {
57 render(createElement(Probe, { src: "/pending.jpg" }));
58 const wrapper = screen.getByTestId("status").parentElement as HTMLElement;
59 const video = document.createElement("video");
60 wrapper.appendChild(video);
61 fireEvent.error(video);
62 fireEvent.load(video);
63 expect(getStatus()).toBe("loading");
64 });
65
66 it("reads an image that broke before React attached", () => {
67 render(createElement(Probe, { src: "/broken.jpg", alt: "A porch" }));
68 expect(getStatus()).toBe("error");
69 });
70
71 it("reads an image that finished before React attached", () => {
72 render(createElement(Probe, { src: "/done.jpg" }));
73 expect(getStatus()).toBe("loaded");
74 });
75
76 it("starts again at loading when the key changes", () => {
77 const { rerender } = render(createElement(Probe, { src: "/pending.jpg" }));
78 fireEvent.error(screen.getByRole("presentation"));
79 rerender(createElement(Probe, { src: "/next.jpg" }));
80 expect(getStatus()).toBe("loading");
81 });
82});
use-image-status.ts
1import {
2 useEffect,
3 useRef,
4 useState,
5 type ReactEventHandler,
6 type RefObject,
7} from "react";
8
9/** Where an image is: still loading, shown, or failed. */
10export type ImageStatus = "loading" | "loaded" | "error";
11
12interface ImageStatusResult<T extends HTMLElement> {
13 /** Attach to the element that contains the rendered `<img>` */
14 ref: RefObject<T | null>;
15 /** `loading` until the image settles, then `loaded` or `error` */
16 status: ImageStatus;
17 /** Alt text of the image that failed, or an empty string, so a fallback can still describe it */
18 alt: string;
19 /** Spread on the same element: load and error do not bubble, so React's capture phase catches them */
20 handlers: {
21 onLoadCapture: ReactEventHandler;
22 onErrorCapture: ReactEventHandler;
23 };
24}
25
26interface ImageResult {
27 /** The image the result belongs to; a new key starts again at `loading` */
28 key: string;
29 status: ImageStatus;
30 /** The failed image's alt text, or an empty string */
31 alt: string;
32}
33
34/**
35 * Tracks whether the `<img>` inside a wrapper has loaded or failed, whatever
36 * component drew it (a plain `<img>` or an app's `next/image`). `resetKey`
37 * names the image: a new key starts again at `loading`. An image that settled
38 * before hydration fires no event, so a new key also reads `complete`.
39 *
40 * @typeParam T The wrapper element type; defaults to `HTMLDivElement`.
41 * @param resetKey Names the image; change it when the image changes.
42 * @returns A ref and the capture handlers for the wrapper, the status, and the alt text of a failed image.
43 * @example
44 * ```tsx
45 * const { ref, status, handlers } = useImageStatus(src);
46 * <div ref={ref} {...handlers}>
47 * {status === "error" ? <Fallback /> : <img src={src} alt="" />}
48 * </div>
49 * ```
50 */
51export const useImageStatus = <T extends HTMLElement = HTMLDivElement>(
52 resetKey: string,
53): ImageStatusResult<T> => {
54 const ref = useRef<T | null>(null);
55 const [result, setResult] = useState<ImageResult>({
56 key: resetKey,
57 status: "loading",
58 alt: "",
59 });
60 const isCurrent = result.key === resetKey;
61 const status = isCurrent ? result.status : "loading";
62
63 useEffect(() => {
64 const img = ref.current?.querySelector("img");
65
66 // An image that settled before React attached fires no event later, so a
67 // finished image with no pixels (a 404 under SSR) is an error now.
68 if (img?.complete) {
69 setResult({
70 key: resetKey,
71 status: img.naturalWidth > 0 ? "loaded" : "error",
72 alt: img.alt,
73 });
74 }
75 }, [resetKey]);
76
77 return {
78 ref,
79 status,
80 alt: status === "error" ? result.alt : "",
81 handlers: {
82 onLoadCapture: (event) => {
83 if (event.target instanceof HTMLImageElement) {
84 setResult({ key: resetKey, status: "loaded", alt: "" });
85 }
86 },
87 onErrorCapture: (event) => {
88 if (event.target instanceof HTMLImageElement) {
89 setResult({ key: resetKey, status: "error", alt: event.target.alt });
90 }
91 },
92 },
93 };
94};
index.ts
1export { useImageStatus } from "./use-image-status.js";
2export type { ImageStatus } from "./use-image-status.js";
use-image-status.example.tsx
1import { useImageStatus } from "./use-image-status.js";
2
3const LOADED_IMAGE =
4 "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='100'><rect width='160' height='100' fill='%23c9b6e4'/></svg>";
5const BROKEN_IMAGE = "/image-that-does-not-exist.jpg";
6
7const StatusImage = ({ src, alt }: { src: string; alt: string }) => {
8 const { ref, status, handlers } = useImageStatus(src);
9
10 return (
11 <div ref={ref} {...handlers}>
12 <img src={src} alt={alt} width={160} height={100} />
13 <p>Status: {status}</p>
14 </div>
15 );
16};
17
18/** An image that loads reports `loaded`; one that breaks reports `error`. */
19export const BasicUseImageStatus = () => (
20 <>
21 <StatusImage src={LOADED_IMAGE} alt="A lilac swatch" />
22 <StatusImage src={BROKEN_IMAGE} alt="A missing picture" />
23 </>
24);