Image loading using suspense #50617
-
SummaryI am having trouble understanding how Suspense works with Next images. I made this dummy code to try to find why the fallback is not being used: https://codesandbox.io/p/sandbox/heuristic-tereshkova-pk7fit What seems to work is, which i completely made up and don't know if its good practice or not is the following example:
This is not a skelleton loading, but i would like to use a skelleton and once it finishes loading, then show the image. What would be a good practice? Additional informationNo response Examplehttps://codesandbox.io/p/sandbox/heuristic-tereshkova-pk7fit |
Beta Was this translation helpful? Give feedback.
Replies: 3 comments 5 replies
-
I am not sure Suspense is necessarily made to work like this. Suspense boundaries only trigger when a component suspends, but loading the image does not suspend. I think you got the right way though. I'd also do it like this: "use client";
import Image from "next/image";
import { useState } from "react";
export const ImageAsync = ({ src, alt, ...props }) => {
const [reveal, setReveal] = useState(false);
const visibility = reveal ? "visible" : "hidden";
const loader = reveal ? "none" : "inline-block";
return (
<div
style={{
width: `${props.width}px`,
height: `${props.height}px`,
position: "relative",
}}
>
<Image
src={src}
alt={alt}
width={props.width}
height={props.height}
{...props}
style={{ ...props.style, visibility }}
onError={() => setReveal(true)}
onLoadingComplete={() => setReveal(true)}
/>
<span
style={{
display: loader,
position: "absolute",
top: 0,
}}
>
Loading...
</span>
</div>
);
}; Also the why do you expect an image to work with suspense? In your code example, it is even a Server Component: // From your CodeSandBox
import Image from "next/image";
import { Suspense } from "react";
const ImageAsync = async ({ src, alt, ...props }) => {
// return <Image src={src} alt={alt} {...props} />
return <Image src={src} alt={alt} {...props} />;
};
export default async function Home() {
return (
<>
<Suspense fallback={<div>loading...</div>}>
{/* @ts-expect-error Async Server Component */}
<ImageAsync
src="https://unsplash.it/200/200"
alt="test"
width={200}
height={200}
/>
</Suspense>
</>
);
} When ran on the server, this only generates the initial mark-up, but it won't wait server side for the image. Once in the client, in terms of loading the image, next/image is just like a regular img tag. It doesn't suspend or anything like that. Do you have any article, or documentation saying so? In other words, SSR generates the HTML markup, and it is from the client that the request for the image is made. For what is worth I tried with:
Next.js complains and it is ugly... but yeah 😅 use the |
Beta Was this translation helpful? Give feedback.
-
In @icyJoseph's answer, there is a single promise outside of the React components. To use dynamically generated promises inside of React components (in React 19 or any version before React will support in-component promises) we can do it by splitting the promise-generating component from the promise-consuming component like so: import { use, useMemo, useState } from 'react'
/**
* Use this in place of <img /> to connect image loading with <Suspense>.
*/
export function AsyncImg({ src, ...props }: JSX.IntrinsicElements['img']) {
const [lastSrc, setLastSrc] = useState<string>()
const [promise, setPromise] = useState(new Promise<Response>(() => {}))
if (src !== lastSrc) {
console.log(lastSrc, src)
setPromise(fetch(src ?? ''))
setLastSrc(src)
}
// Delegate to a sub-component so that use(promise) will work properly (cannot
// currently use(promise) from the same component where the promise is created,
// this may be allowed in future React)
return <AsyncImg_ {...{ ...props, promise }} />
}
function AsyncImg_({ promise, ...props }: JSX.IntrinsicElements['img'] & { promise: Promise<Response> }) {
const blobPromise = useMemo(() => promise.then((r) => r.blob()).then((b) => URL.createObjectURL(b)), [promise])
const blobUrl = use(blobPromise)
return <img {...{ ...props, src: blobUrl }} />
} Boy is that ugly. But once it is implemented, it is easy to use: export function MyComponent() {
return <Suspense fallback={<SkeletonLoadingImage />}>
<AsyncImg src="./some/image.jpg" />
</Suspense>
} Rename it to |
Beta Was this translation helpful? Give feedback.
-
Here is my way to use Next/Image with Suspense. https://www.npmjs.com/package/suspense-next-image import Image, {getImageProps, type ImageProps} from 'next/image';
import {use, useSyncExternalStore} from 'react';
const imagePromises = new Map<string, Promise<void>>();
export function SuspenseImage(props: ImageProps) {
const isSSR = useIsSSR();
if (isSSR) {
throw new Error(
'SuspenseImage must be used on the client side only. ' +
'Rendering it on the server is not supported.'
);
}
const {src} = getImageProps(props).props;
let imagePromise = imagePromises.get(src);
if (!imagePromise) {
const {promise, resolve, reject} = Promise.withResolvers<void>();
const img = new window.Image();
img.src = src;
img.onload = () => resolve();
img.onerror = () => reject();
imagePromises.set(src, (imagePromise = promise));
}
use(imagePromise);
return <Image {...props} />;
}
function useIsSSR() {
const isSSR = useSyncExternalStore(
subscribe,
function getSnapshot() {
return false;
},
function getServerSnapshot() {
return true;
}
);
return isSSR;
}
function subscribe() {
return () => {};
} |
Beta Was this translation helpful? Give feedback.
I am not sure Suspense is necessarily made to work like this. Suspense boundaries only trigger when a component suspends, but loading the image does not suspend.
I think you got the right way though. I'd also do it like this: