Skip to content

Suspense

Polyfill of the React <Suspense> component.

Renders children normally. When a descendant component suspends by throwing a Promise (the standard Suspense contract used by React.lazy, data-fetching libraries such as SWR, React Query, and Relay, and the use() hook), fallback is rendered in its place until the Promise resolves, at which point children are rendered again.

How it works

The polyfill relies on the same mechanism used by ErrorBoundary: getDerivedStateFromError is called synchronously when any descendant throws during rendering. If the thrown value is a Promise, the boundary marks itself as suspended and renders fallback. Once the Promise settles (resolved or rejected), the boundary resets and React re-renders children.

Differences from native React <Suspense>

  • No concurrent rendering: the native <Suspense> integrates with React's concurrent mode to render suspended subtrees in the background without blocking the UI. This polyfill performs a synchronous setState on Promise resolution, which may cause a brief flash of the fallback even when the Promise resolves immediately.
  • No startTransition integration: the native <Suspense> can keep the previous UI visible during a transition while the new subtree loads. This polyfill always shows fallback immediately on suspend.
  • No streaming / server-side rendering support: the native <Suspense> supports streaming HTML from the server. This polyfill is client-only.
  • Promise rejection: when a suspended Promise rejects, this polyfill resets the suspended state and re-renders children, which will throw again — resulting in an infinite loop unless the child handles the error internally. Wrap with an ErrorBoundary to handle rejection gracefully.

See: React Suspense docs

Demo

INFO

The component demonstrates the Suspense polyfill with two scenarios:

  • Lazy component: a button triggers the lazy load of a component via React.lazy. While loading, a spinner fallback is shown. Once ready, the component appears.
  • Data fetching: a button triggers a simulated async data fetch (1.5s delay) using the Suspense throw-Promise contract. While fetching, a skeleton fallback is shown. Once resolved, the data is displayed. Both scenarios show the fallback prop in action and demonstrate that Suspense catches thrown Promises from descendant components.
Loading demo…
Show source code
tsx
import { useState } from "react";
import { Suspense } from "../../..";

// ---------------------------------------------------------------------------
// Lazy component scenario
// ---------------------------------------------------------------------------

const LazyChild = (() => {
	let loaded = false;
	let promise: Promise<void> | null = null;

	return function LazyChild() {
		if (!loaded) {
			if (!promise) {
				promise = new Promise<void>(res => setTimeout(() => { loaded = true; res(); }, 1200));
			}
			throw promise;
		}
		return <p style={{ margin: 0, color: "green" }}>✓ Lazy component loaded!</p>;
	};
})();

// ---------------------------------------------------------------------------
// Data fetching scenario
// ---------------------------------------------------------------------------

type Status = "idle" | "pending" | "done";

const createResource = (delay: number) => {
	let status: Status = "pending";
	let result = "";
	const promise = new Promise<string>(res =>
		setTimeout(() => { result = `Fetched at ${new Date().toLocaleTimeString()}`; status = "done"; res(result); }, delay)
	);
	return {
		read(): string {
			if (status === "pending") throw promise;
			return result;
		},
	};
};

type Resource = ReturnType<typeof createResource>;

const DataChild = ({ resource }: { resource: Resource }) => {
	const data = resource.read();
	return <p style={{ margin: 0, color: "#1e88e5" }}>✓ {data}</p>;
};

// ---------------------------------------------------------------------------
// Demo component
// ---------------------------------------------------------------------------

export default function SuspenseDemo() {
	const [showLazy, setShowLazy] = useState(false);
	const [resource, setResource] = useState<Resource | null>(null);

	return (
		<div style={{ display: "grid", gap: 24, maxWidth: 420, margin: "0 auto" }}>

			{/* Lazy component */}
			<div style={{ border: "1px solid #e0e0e0", borderRadius: 8, padding: 16 }}>
				<p style={{ margin: "0 0 12px", fontWeight: "bold" }}>Lazy component</p>
				<button onClick={() => setShowLazy(true)} disabled={showLazy}>
					Load component
				</button>
				{showLazy && (
					<div style={{ marginTop: 12 }}>
						<Suspense fallback={<p style={{ margin: 0, color: "#999" }}>⏳ Loading…</p>}>
							<LazyChild />
						</Suspense>
					</div>
				)}
			</div>

			{/* Data fetching */}
			<div style={{ border: "1px solid #e0e0e0", borderRadius: 8, padding: 16 }}>
				<p style={{ margin: "0 0 12px", fontWeight: "bold" }}>Data fetching</p>
				<button onClick={() => setResource(createResource(1500))}>
					{resource ? "Fetch again" : "Fetch data"}
				</button>
				{resource && (
					<div style={{ marginTop: 12 }}>
						<Suspense
							fallback={
								<div style={{ background: "#f5f5f5", borderRadius: 4, height: 20, width: "60%", margin: 0, color: 'black' }}>Fallback</div>
							}
						>
							<DataChild resource={resource} />
						</Suspense>
					</div>
				)}
			</div>

		</div>
	);
}

Types

SuspenseState

Internal state managed by the Suspense polyfill.

PropertyTypeRequiredDescription
isSuspendedbooleantrue when a descendant component has thrown a Promise that has not yet resolved, causing the fallback UI to be rendered in its place. Reset to false once the Promise resolves and the component tree is ready to be rendered.
suspendedPromisePromise<unknown> \| nullThe Promise thrown by a suspended descendant, or null when no component is currently suspended. Used internally to schedule a re-render once the Promise settles.

SuspenseProps

Props accepted by the Suspense polyfill.

PropertyTypeRequiredDescription
fallbackReactNodeContent rendered while one or more descendant components are suspended (i.e. while a thrown Promise is pending). Accepts any valid React node — typically a spinner, skeleton, or loading message. When the suspended Promise resolves, fallback is replaced by the original children.

Released under the MIT License.