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.
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";56const Probe = ({ src, alt = "" }: { src: string; alt?: string }) => {7 const { ref, status, alt: failedAlt, handlers } = useImageStatus(src);89 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};1718// jsdom never loads images, so `complete` and `naturalWidth` are stubbed: an19// 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});3435const getStatus = () => screen.getByTestId("status").textContent;3637describe("useImageStatus", () => {38 it("starts at loading", () => {39 render(createElement(Probe, { src: "/pending.jpg" }));40 expect(getStatus()).toBe("loading");41 });4243 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 });4849 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 });5556 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 });6566 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 });7071 it("reads an image that finished before React attached", () => {72 render(createElement(Probe, { src: "/done.jpg" }));73 expect(getStatus()).toBe("loaded");74 });7576 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";89/** Where an image is: still loading, shown, or failed. */10export type ImageStatus = "loading" | "loaded" | "error";1112interface 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}2526interface 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}3334/**35 * Tracks whether the `<img>` inside a wrapper has loaded or failed, whatever36 * 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 settled38 * 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 * @example44 * ```tsx45 * 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";6263 useEffect(() => {64 const img = ref.current?.querySelector("img");6566 // An image that settled before React attached fires no event later, so a67 // 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]);7677 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";23const 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";67const StatusImage = ({ src, alt }: { src: string; alt: string }) => {8 const { ref, status, handlers } = useImageStatus(src);910 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};1718/** 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);