useImageOnLoad tracks one image's loading lifecycle — loading, then loaded or error — and hands it back as React state you can render a spinner, the image, or a fallback against. You attach the hook's ref to your own <img> element and read its status. The catch is the browser cache: an image you have already seen can finish loading before your listener attaches, so relying on the load event alone leaves the status stuck at loading even though the picture is right there on screen.
Implement useImageOnLoad(src). It returns { ref, status }: spread ref on an <img> and read status. When src changes, it resets to loading and tracks the new image.
function useImageOnLoad(src) {
// returns { ref, status }
// ref: attach to your image -> <img ref={ref} src={src} />
// status: 'loading' | 'loaded' | 'error'
}
const { ref, status } = useImageOnLoad(src);
return (
<div>
{status === 'loading' && <Spinner />}
<img ref={ref} src={src} alt="" hidden={status !== 'loaded'} />
{status === 'error' && <BrokenIcon />}
</div>
);
// status is 'loading', then 'loaded' once the image finishes.
// Second visit: the image is served from cache. It is already `complete`
// before the effect runs, so no `load` event fires. status must still become
// 'loaded' from a synchronous check — not stay stuck at 'loading'.
loading. A load event means loaded; an error event means error.img.complete synchronously so you do not miss it.complete image with naturalWidth === 0 finished but has no pixels — that is a failed load, so report error.src. When src changes, reset to loading, track the new image, and clean up the old listeners.<img> you render; it does not render one for you. You still set src, alt, and sizing.decode(), and srcset are extensions — see the solution's "Going further".You will attach a ref to a real <img>, listen for its load and error events, and — because a cached image can beat your listener — also check whether it is already finished the moment the ref lands.
You are rendering an avatar, a hero image, a product photo. You want a spinner while it loads, the image once it is ready, and a fallback if it breaks. The obvious tool is the image's load event, and it works perfectly the first time. Then the user navigates back, the browser serves the same image from cache, and the spinner never goes away — the picture is fully painted, but your hook still says loading. The bug only appears on a warm cache, on a second visit, which is exactly why it is so confounding to reproduce.
An image is a tiny state machine: it starts at loading and ends at exactly one of loaded or error. Two different things can move it there. Either a load or error event fires — for an image the browser fetches after your component mounts — or the image is already finished the instant your ref attaches, because it was cached, in which case no event will ever come and you have to read img.complete yourself. The whole difficulty is a race: on a cold load your listener is attached before the event; on a warm cache the event already happened before you listened.
The obvious version attaches the listeners in an effect and waits for one to fire:
function useImageOnLoad(src) {
const ref = useRef(null);
const [status, setStatus] = useState('loading');
useLayoutEffect(() => {
const img = ref.current;
if (!img) return;
setStatus('loading');
const onLoad = () => setStatus('loaded');
const onError = () => setStatus('error');
img.addEventListener('load', onLoad);
img.addEventListener('error', onError);
return () => {
img.removeEventListener('load', onLoad);
img.removeEventListener('error', onError);
};
}, [src]);
return { ref, status };
}
On a cold load this is correct — the fetch happens after mount, so the event lands after addEventListener. But a cached image is complete the instant React sets src; its load already fired before the effect ran, so you have attached a listener to an event that is never coming again. The status freezes at loading while the image sits fully rendered on the page.
const { useRef, useState, useLayoutEffect } = require('react');
function useImageOnLoad(src) {
const ref = useRef(null);
const [status, setStatus] = useState('loading');
// Re-run whenever `src` changes: reset, then (re)wire the new image.
useLayoutEffect(() => {
const img = ref.current;
if (!img) return; // ref was never attached to an element
setStatus('loading'); // a fresh src goes back to loading
const onLoad = () => setStatus('loaded');
const onError = () => setStatus('error');
// THE CACHED CASE. A warm-cache image is already `complete` by the time
// this effect runs — its load/error fired before we could listen — so
// resolve it synchronously. A complete image with zero natural width
// finished but has no pixels: that is a broken load, so it is an error.
if (img.complete) {
setStatus(img.naturalWidth > 0 ? 'loaded' : 'error');
return; // done — no listeners, nothing to clean up
}
// The still-loading case: catch the event when it lands.
img.addEventListener('load', onLoad);
img.addEventListener('error', onError);
return () => {
img.removeEventListener('load', onLoad);
img.removeEventListener('error', onError);
};
}, [src]);
return { ref, status };
}
module.exports = { useImageOnLoad };
The fix is one branch. Before wiring the listeners, ask the image whether it is already done — if (img.complete). A cached image answers yes, and you resolve it right there. Only when it is genuinely still loading do you fall through to addEventListener, so you keep the event path for the cold case and add a synchronous path for the warm one. That is the whole lesson: you need both.
Two more details earn their keep. Resetting with setStatus('loading') at the top and keying the effect on [src] means a new src restarts the whole dance — reset, check complete, then resolve or listen. And useLayoutEffect runs after the DOM updates but before the browser paints, so a cached image never flashes a spinner for a frame on its way to loaded. (On the server useLayoutEffect warns; a production hook swaps in a useIsomorphicLayoutEffect — see "Going further".)
complete tells you the browser is done, not that it succeeded. A 404 or a corrupt file also ends up complete — but with naturalWidth === 0, because there are no pixels to measure. So the read has two levels: is it complete, and if so, does it have width?
Trace a cached avatar. The user opens their profile a second time, and avatar.jpg is already in cache.
status is loading (the initial value). React creates the <img>, sets its src, and attaches your ref.img is the node. setStatus('loading') is a no-op (already loading). You check img.complete: the cached image is already true. img.naturalWidth is 256, greater than zero, so you setStatus('loaded') and return early — no listeners, nothing to clean up.loaded state, so the first thing the user sees is the image itself, not a spinner.No load event ever fired. The naive hook would still be showing that spinner.
loading. Check img.complete on mount.complete alone — a broken image is complete too. Gate on img.naturalWidth > 0 to tell a real load from a failed one.src — key the effect on [src] and set loading at the top, or the next image inherits the previous image's status.ref.current before it exists — the effect can run with ref.current null when the ref was never attached. Guard with an early return.load and error listeners on unmount and before re-running, or a fast run of src changes leaks stale handlers.img.decode() returns a promise that resolves once the image is decoded and ready to paint, which avoids a decode hitch on the frame it first appears. Konva's use-image calls it before flipping to loaded.status is loaded, cross-fade from a tiny blurred thumbnail to the full image. This is the pattern behind the other widely-copied hook that happens to share this name.srcset and currentSrc — with a responsive srcset, naturalWidth is the width of whichever candidate the browser actually picked; read img.currentSrc to learn which URL loaded.loading="lazy" versus a JS hook — the native attribute defers the fetch until the image nears the viewport, a different job from tracking load state. The two compose; neither replaces the other.new Image() instead of a ref — use-image creates its own detached Image and attaches handlers before setting src, sidestepping the race by construction — but it observes that private object, not the actual <img> the user sees, so it cannot report on (or style) the visible element, which is why the ref shape wins for placeholders.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useImageOnLoad tracks one image's loading lifecycle — loading, then loaded or error — and hands it back as React state you can render a spinner, the image, or a fallback against. You attach the hook's ref to your own <img> element and read its status. The catch is the browser cache: an image you have already seen can finish loading before your listener attaches, so relying on the load event alone leaves the status stuck at loading even though the picture is right there on screen.
Implement useImageOnLoad(src). It returns { ref, status }: spread ref on an <img> and read status. When src changes, it resets to loading and tracks the new image.
function useImageOnLoad(src) {
// returns { ref, status }
// ref: attach to your image -> <img ref={ref} src={src} />
// status: 'loading' | 'loaded' | 'error'
}
const { ref, status } = useImageOnLoad(src);
return (
<div>
{status === 'loading' && <Spinner />}
<img ref={ref} src={src} alt="" hidden={status !== 'loaded'} />
{status === 'error' && <BrokenIcon />}
</div>
);
// status is 'loading', then 'loaded' once the image finishes.
// Second visit: the image is served from cache. It is already `complete`
// before the effect runs, so no `load` event fires. status must still become
// 'loaded' from a synchronous check — not stay stuck at 'loading'.
loading. A load event means loaded; an error event means error.img.complete synchronously so you do not miss it.complete image with naturalWidth === 0 finished but has no pixels — that is a failed load, so report error.src. When src changes, reset to loading, track the new image, and clean up the old listeners.<img> you render; it does not render one for you. You still set src, alt, and sizing.decode(), and srcset are extensions — see the solution's "Going further".You will attach a ref to a real <img>, listen for its load and error events, and — because a cached image can beat your listener — also check whether it is already finished the moment the ref lands.
You are rendering an avatar, a hero image, a product photo. You want a spinner while it loads, the image once it is ready, and a fallback if it breaks. The obvious tool is the image's load event, and it works perfectly the first time. Then the user navigates back, the browser serves the same image from cache, and the spinner never goes away — the picture is fully painted, but your hook still says loading. The bug only appears on a warm cache, on a second visit, which is exactly why it is so confounding to reproduce.
An image is a tiny state machine: it starts at loading and ends at exactly one of loaded or error. Two different things can move it there. Either a load or error event fires — for an image the browser fetches after your component mounts — or the image is already finished the instant your ref attaches, because it was cached, in which case no event will ever come and you have to read img.complete yourself. The whole difficulty is a race: on a cold load your listener is attached before the event; on a warm cache the event already happened before you listened.
The obvious version attaches the listeners in an effect and waits for one to fire:
function useImageOnLoad(src) {
const ref = useRef(null);
const [status, setStatus] = useState('loading');
useLayoutEffect(() => {
const img = ref.current;
if (!img) return;
setStatus('loading');
const onLoad = () => setStatus('loaded');
const onError = () => setStatus('error');
img.addEventListener('load', onLoad);
img.addEventListener('error', onError);
return () => {
img.removeEventListener('load', onLoad);
img.removeEventListener('error', onError);
};
}, [src]);
return { ref, status };
}
On a cold load this is correct — the fetch happens after mount, so the event lands after addEventListener. But a cached image is complete the instant React sets src; its load already fired before the effect ran, so you have attached a listener to an event that is never coming again. The status freezes at loading while the image sits fully rendered on the page.
const { useRef, useState, useLayoutEffect } = require('react');
function useImageOnLoad(src) {
const ref = useRef(null);
const [status, setStatus] = useState('loading');
// Re-run whenever `src` changes: reset, then (re)wire the new image.
useLayoutEffect(() => {
const img = ref.current;
if (!img) return; // ref was never attached to an element
setStatus('loading'); // a fresh src goes back to loading
const onLoad = () => setStatus('loaded');
const onError = () => setStatus('error');
// THE CACHED CASE. A warm-cache image is already `complete` by the time
// this effect runs — its load/error fired before we could listen — so
// resolve it synchronously. A complete image with zero natural width
// finished but has no pixels: that is a broken load, so it is an error.
if (img.complete) {
setStatus(img.naturalWidth > 0 ? 'loaded' : 'error');
return; // done — no listeners, nothing to clean up
}
// The still-loading case: catch the event when it lands.
img.addEventListener('load', onLoad);
img.addEventListener('error', onError);
return () => {
img.removeEventListener('load', onLoad);
img.removeEventListener('error', onError);
};
}, [src]);
return { ref, status };
}
module.exports = { useImageOnLoad };
The fix is one branch. Before wiring the listeners, ask the image whether it is already done — if (img.complete). A cached image answers yes, and you resolve it right there. Only when it is genuinely still loading do you fall through to addEventListener, so you keep the event path for the cold case and add a synchronous path for the warm one. That is the whole lesson: you need both.
Two more details earn their keep. Resetting with setStatus('loading') at the top and keying the effect on [src] means a new src restarts the whole dance — reset, check complete, then resolve or listen. And useLayoutEffect runs after the DOM updates but before the browser paints, so a cached image never flashes a spinner for a frame on its way to loaded. (On the server useLayoutEffect warns; a production hook swaps in a useIsomorphicLayoutEffect — see "Going further".)
complete tells you the browser is done, not that it succeeded. A 404 or a corrupt file also ends up complete — but with naturalWidth === 0, because there are no pixels to measure. So the read has two levels: is it complete, and if so, does it have width?
Trace a cached avatar. The user opens their profile a second time, and avatar.jpg is already in cache.
status is loading (the initial value). React creates the <img>, sets its src, and attaches your ref.img is the node. setStatus('loading') is a no-op (already loading). You check img.complete: the cached image is already true. img.naturalWidth is 256, greater than zero, so you setStatus('loaded') and return early — no listeners, nothing to clean up.loaded state, so the first thing the user sees is the image itself, not a spinner.No load event ever fired. The naive hook would still be showing that spinner.
loading. Check img.complete on mount.complete alone — a broken image is complete too. Gate on img.naturalWidth > 0 to tell a real load from a failed one.src — key the effect on [src] and set loading at the top, or the next image inherits the previous image's status.ref.current before it exists — the effect can run with ref.current null when the ref was never attached. Guard with an early return.load and error listeners on unmount and before re-running, or a fast run of src changes leaks stale handlers.img.decode() returns a promise that resolves once the image is decoded and ready to paint, which avoids a decode hitch on the frame it first appears. Konva's use-image calls it before flipping to loaded.status is loaded, cross-fade from a tiny blurred thumbnail to the full image. This is the pattern behind the other widely-copied hook that happens to share this name.srcset and currentSrc — with a responsive srcset, naturalWidth is the width of whichever candidate the browser actually picked; read img.currentSrc to learn which URL loaded.loading="lazy" versus a JS hook — the native attribute defers the fetch until the image nears the viewport, a different job from tracking load state. The two compose; neither replaces the other.new Image() instead of a ref — use-image creates its own detached Image and attaches handlers before setting src, sidestepping the race by construction — but it observes that private object, not the actual <img> the user sees, so it cannot report on (or style) the visible element, which is why the ref shape wins for placeholders.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.