A React 19 component that enhances React's Suspense
with optional features like promise resolved values handling (onSuccess
), error handling (onError
), retry functionality of failing promises (retry
), caching (cache
), and timeout fallbacks (timeouts
).
- Installation
- Usage
- Key Features
- React's
Suspense
(onlyfallback
and/orchildren
props) - Promise Resolution Handling With
onSuccess
- Error Handling With
onError
(*Client Only) - Retry Functionality With
retry
(*Client Only) - Caching With
cache
andresourceId
- Timeout Fallbacks With
timeouts
AndtimeoutFallbacks
- All Props Can Be Used Together In The Client
- Invalid Combination Of Props In The Server (
productionPropsErrorFallback
) children
, The Resource Prop- The
resourceId
Prop - The
cacheAPI
- Props Reference
- Requirements
- License
To install the package, run:
npm i react-enhanced-suspense
You can import the component in two ways:
-
Named import: Use this if you prefer explicit names:
import { EnhancedSuspense } from "react-enhanced-suspense";
-
Default import: Use this for a shorter, more familiar syntax (like React’s
Suspense
):import Suspense from "react-enhanced-suspense";
Both are the same component under the hood. Pick the one that suits your project!
This component can be used as a substitute for React's Suspense
.
-
Promise Resolution Handling: Use
onSuccess
to transform resolved promise or React Context values. -
(*)Error Handling: Use
onError
to wrap React'sSuspense
in anErrorBoundary
for custom error rendering (Client only). -
(*)Retry Functionality: Automatically retry failed promises with
retry
, configurable withretryCount
,retryDelay
,retryBackoff
, andonRetryFallback
(Client only). -
Caching: Store promise results in memory or
localStorage
(or any custom storage) withcache
,resourceId
,cacheTTL
,cacheVersion
, andcachePersist
. -
Timeout Fallbacks: Update the fallback UI dynamically with
timeouts
andtimeoutFallbacks
for long-running operations. -
React's
Suspense
: This component is React'sSuspense
when onlyfallback
orchildren
props are used. -
TypeScript Support: Fixes TypeScript errors when using React Context with
Suspense
, unlike React’s nativeSuspense
.
(*): These props can only be used in the Client.
import Suspense from "react-enhanced-suspense";
export default function Component({ promise }: { promise: Promise<string> }) {
return (
<>
<div>Hey</div>
<div>
<Suspense fallback="Loading...">{promise}</Suspense>
</div>
</>
);
}
Can be used either in the Client or Server.
EnhancedSuspense
fixes a Typescript error that appears when using React Context as children
in React's Suspense
:
"use client";
import Suspense from "react-enhanced-suspense";
import { createContext, Suspense as ReactSuspense } from "react";
const MyContext = createContext("Default value");
export default function ShowContext() {
return (
<>
<Suspense>{MyContext}</Suspense> // <-- No Typescript error
<ReactSuspense>{MyContext}</ReactSuspense> // <-- Typescript error: Type 'Context<string>' is not assignable to type 'ReactNode'.
</>
);
}
import Suspense from "react-enhanced-suspense";
export default function Component({ promise }: { promise: Promise<string[]> }) {
return (
<>
<div>Hey</div>
<div>
<Suspense
fallback="Loading..."
onSuccess={(data) => data.map((item) => <div key={item}>{item}</div>)}
>
{promise}
</Suspense>
</div>
</>
);
}
Can be used either in the Client or Server.
EnhancedSuspense
also supports React Context as a Usable
resource (children
prop):
"use client";
import Suspense from "react-enhanced-suspense";
import { createContext } from "react";
const MyContext = createContext("Default value");
export default function Context() {
return (
<Suspense onSuccess={(value) => <div>Context value: {value}</div>}>
{MyContext}
</Suspense>
);
}
This renders Context value: Default value
.
"use client";
import Suspense from "react-enhanced-suspense";
export default function Component() {
return (
<Suspense onError={(error) => <div>{error.message}</div>}>
{
new Promise((resolve, reject) =>
setTimeout(() => reject("Failed"), 1000)
)
}
</Suspense>
);
}
The onError
prop wraps React's Suspense
in an ErrorBoundary
component for custom error rendering. It always expects an Error
object, no matter what the rejected value of the promise is.
Can only be used in the Client.
With EnhancedSuspense
you can automatically retry failed promises with configurable options:
"use client";
import Suspense from "react-enhanced-suspense";
export default function Component() {
return (
<>
<div>Hey</div>
<Suspense
retry
retryCount={10} // number of retries; default: 1
retryDelay={500} // delay between retries in ms; default: 0
retryBackoff="linear" // backoff for retries; default: undefined
onRetryFallback={(attempt) => <div>{`Retrying ${attempt}...`}</div>} // fallback to show in each retry attempt; default: undefined
fallback="Loading..." // fallback to show on first attempt
>
{() =>
new Promise<string[]>((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.7) {
resolve(["Roger", "Alex"]);
} else {
reject("Fail on data fetching");
}
}, 1000);
})
}
</Suspense>
</>
);
}
When using retry
, the resource (the children
prop) should be a function returning a promise, rather than a promise itself. If not, the retries will be on the same initially rejected promise and would have no effect.
retryBackoff
admits the following values: linear
, exponential
, or a function with signature (attemptIndex:number, retryDelay:number)=>number
.
Works only in the Client.
Cache promise results with cache
and resourceId
props:
import Suspense from "react-enhanced-suspense";
export default function Component() {
return (
<>
<div>Hey</div>
<Suspense
cache
cacheTTL={2000} // expiration time in ms for the cache; default: undefined
cacheVersion={1} // number, when this changes invalidates cache; default: undefined
cachePersist // whether to persist or not cache in localStorage; default: false
resourceId="my-promise" // the id of the resource; without it the cache takes no effect
>
{
new Promise<string[]>((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.2) {
resolve(["Roger", "Alex"]);
} else {
reject("Fail on data fetching");
}
}, 1000);
})
}
</Suspense>
</>
);
}
Can be used either in the Client or the Server.
Update fallback UI for long-running operations:
import Suspense from "react-enhanced-suspense";
const VERY_LONG_TIME = 15000;
export default function Component() {
return (
<Suspense
fallback="Loading..."
timeouts={[3000, 6000, 10000]}
timeoutFallbacks={[
"Still working...",
"Taking longer...",
<div style={{ color: "green" }}>Almost there...</div>,
]}
>
{
new Promise<string>((resolve) =>
setTimeout(() => resolve("Done"), VERY_LONG_TIME)
)
}
</Suspense>
);
}
Can be used either in the Client or the Server.
"use client";
import Suspense from "react-enhanced-suspense";
export default function Component() {
return (
<Suspense
retry
retryCount={15}
onRetryFallback={(attempt) => <div>{`Retry ${attempt}...`}</div>}
cache
cacheTTL={2000}
resourceId="my-promise"
onError={(error) => <div>{error.message}</div>}
onSuccess={(value) => value.map((item) => <div key={item}>{item}</div>)}
fallback="Loading..."
timeouts={[1000, 2000, 3000]}
timeoutFallbacks={["Three...", "Two...", "One..."]}
>
{() =>
new Promise<string[]>((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.9) {
resolve(["Roger", "Alex"]);
} else {
reject("Fail on data fetching");
}
}, 4000);
})
}
</Suspense>
);
}
onRetryFallback
takes precedence over timeouts
and timeoutFallbacks
. In the component above, from 0 to 1s, Loading...
will render. From 1s to 2s, Three...
; from 2s to 3s, Two...
; from 3s to 4s, One...
. Then, if promise is rejected, Retry 1...
will render for 4s, then, if rejected again, Retry 2...
, and so on, until promise is resolved or the number of retries are 15.
As always, when retry
is used, children
, the resource, should be a function returning a promise.
When used in the Server, there are certain combination of props which are not allowed. This happens when the component is a React Client Component and has props which are functions. For example, resourceId
turns the component into a Client Component. If we use then onSuccess
, this is not allowed in the Server, because functions are not serializable.
// In the Server
{
/* resourceId + onSuccess: ❌ BAD (React Client Component + Function) */
}
<Suspense
resourceId="my-cache-key"
onSuccess={(data) => <div>{data}</div>}
productionPropsErrorFallback={
<div>Component unavailable - please contact support</div>
}
>
{promise}
</Suspense>;
When this happens, that the component is a React Client Component and is used in the Server passing some prop as a function, the app will not crash. Instead, will show a message in development signaling that point, and in production will show either null
or, if provided, productionPropsErrorFallback
.
Other props that turns the component into a Client Component, apart from resourceId
, are timeouts
, onError
, retry
, cache
, and when children
, the resource, is a function. So when using onError
, or children
as a function, the component cannot be used in the Server (because it's a Client Component with props as functions).
But a Client Component can be used in the Server when no props are functions. For example, this is allowed in the Server:
// In the Server
{
/* timeouts: ✅ React Client Component, no props as functions */
}
<Suspense
fallback="Loading..."
timeouts={[3000, 6000, 10000]}
timeoutFallbacks={[
<div>Timeout 1</div>,
<div>Timeout 2</div>,
<div
F438
>Timeout 3</div>,
]}
>
{promise}
</Suspense>;
onSuccess
doesn't turn the component into a Client Component. So, despite it is a function, it can be used in the Server:
// In the Server
{
/* onSuccess: ✅ React Server Component */
}
<Suspense fallback="Loading..." onSuccess={(data) => <div>{data}</div>}>
{promise}
</Suspense>;
cache
can also be used in the Server if children
is not a function (remember that cache
only works with resourceId
):
// In the server
{
/* cache + resourceId: ✅ React Client Component, no props as functions */
}
<Suspense fallback="Loading..." cache resourceId="my-promise" cachePersist>
{promise}
</Suspense>;
Because retry
, in order to work properly, requires children
to be a function, cannot be used in the Server, and only in the Client.
Props | React Client Component | Server Allowed |
---|---|---|
children as a function |
✅ | ❌ |
onError |
✅ | ❌ |
timeouts |
✅ | ✅ if no props as function (e.g. ❌ with onSuccess ) |
resourceId |
✅ | ✅ if no props as function (e.g. ❌ with onSuccess ) |
retry |
✅ | ❌ because used with children as a function |
cache |
✅ | ✅ if no props as function (e.g. ❌ with onSuccess ) |
onSuccess |
❌ | ✅ |
fallback |
❌ | ✅ |
children not a function |
❌ | ✅ |
In the Client all props are allowed.
children
is the resource. It can be either a ReactNode
, a Usable
(which is a promise or a React Context), or a function returning a promise. When used with onSuccess
, it can be a Usable
or a function returning a promise. When used without onSuccess
, it can be a ReactNode
, a Usable
that resolves to a ReactNode
, or a function returning a promise that resolves to a ReactNode
.
As has been stated, resourceId
turns the component into a Client Component, and it's necessary for cache
to take effect (it's the cache key for storing the value into memory or localStorage or custom storage). resourceId
it's a string. Apart from serving as a cache key when cache
is used, it is also useful when the resource, the children
prop, changes dynamically. When resourceId
changes, the evaluation of the resource is cancelled and a new evaluation starts. That is, a change in resourceId
triggers a new evaluation of the resource, cancelling any pending evaluations.
React's Suspense
evaluates the resource in every render if the resource is not memoized and it is not a state variable, that is, if it changes in every render. EnhancedSuspense
, on the other hand, in its Client version, doesn't need to memoize the resource. It is stable between rerenders. Therefore, if you want to reevalute a resource, you must supply a resourceId
and change it.
"use client";
import Suspense from "react-enhanced-suspense";
import { useState, useEffect } from "react";
export default function Component() {
const [resource, setResource] = useState(
() =>
new Promise<string[]>((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.002) {
resolve(["Roger", "Alex"]);
} else {
reject("Fail on data fetching");
}
}, 2000);
})
);
const [resourceId, setResourceId] = useState("first");
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (!isMounted) return;
setResourceId("second");
}, [resource]);
return (
<>
<button
onClick={() =>
setResource(
new Promise<string[]>((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.002) {
resolve(["Alex", "Roger"]);
} else {
reject("Fail on data fetching");
}
}, 2000);
})
)
}
>
change resource
</button>
<Suspense
fallback="Loading..."
onSuccess={(values) =>
values.map((item) => <div key={item}>{item}</div>)
}
// Reevaluates the resource when pressing the button (not a Client Component)
>
{resource}
</Suspense>
<Suspense
fallback="Loading..."
resourceId="fixed"
onSuccess={(values) =>
values.map((item) => <div key={item}>{item}</div>)
}
// DON'T Reevaluate the resource when pressing button (React Client Component + resourceId constant)
>
{resource}
</Suspense>
<Suspense
fallback="Loading..."
resourceId={resourceId}
onSuccess={(values) =>
values.map((item) => <div key={item}>{item}</div>)
}
// Reevaluate the resource when pressing button (React Client Component + resourceId changes)
>
{resource}
</Suspense>
</>
);
}
The first Suspense
will reevaluate the resource when pressing the button because the props used doesn't turn it into a React Client Component (only fallback
, children
not being a function, and onSuccess
are used).
The second Suspense
will NOT reevaluate the resource despite pressing the button and changing it because resourceId
turns the component into a Client Component and is fixed (constant, doesn't change when pressing the button).
The third Suspense
will reevaluate the resource when pressing the button because of the useEffect
, that changes the resourceId
when the resource changes.
The correct way to implement it would be (a more realistic scenario):
"use client";
import Suspense from "react-enhanced-suspense";
import { useState, useEffect } from "react";
export default function Component() {
const [prop, setProp] = useState(true);
const resource = prop
? new Promise<string[]>((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.002) {
resolve(["Roger", "Alex"]);
} else {
reject("Fail on data fetching");
}
}, 2000);
})
: new Promise<string[]>((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.002) {
resolve(["Alex", "Roger"]);
} else {
reject("Fail on data fetching");
}
}, 2000);
});
const [resourceId, setResourceId] = useState(`my-promise-${prop}`);
useEffect(() => {
setResourceId(`my-promise-${prop}`);
}, [prop]);
return (
<>
<button onClick={() => setProp((currentValue) => !currentValue)}>
Toggle prop
</button>
<Suspense
fallback="Loading..."
// Reevaluates the resource on EVERY RENDER (not a Client Component, resource not memoized and not a state variable)
>
{resource}
</Suspense>
<Suspense
fallback="Loading..."
resourceId="fixed"
// DON'T Reevaluate the resource on every render, NEITHER when the button is pressed.
>
{resource}
</Suspense>
<Suspense
fallback="Loading..."
resourceId={resourceId}
// DON'T Reevaluate the resource on every render, only WHEN the button is pressed.
>
{resource}
</Suspense>
</>
);
}
Together with the component EnhancedSuspense
, a cacheAPI
is also exported:
const cacheAPI: Readonly<{
setCacheSizeLimit(bytes: number | null, entries?: number | null): void;
setCustomStorage(storage: CustomCacheStorage | null): void;
cleanupCache(): void;
startAutomaticCacheCleanup(intervalMs?: number): void;
stopAutomaticCacheCleanup(): void;
getCacheStatus(): {
isCustomStorage: boolean;
entryCount: number | undefined;
persistentCount: number | undefined;
expirationCount: number | undefined;
isCleanupActive: boolean;
};
clearCache(options?: { clearLocalStorage?: boolean }): void;
}>;
cleanupCache()
removes from cache all expired entries. clearCache()
clears all the cache. setCustomStorage
sets a custom storage for the cache, where:
type CustomCacheStorage = {
get(key: string): CacheEntry | null;
set(key: string, value: any, ttl?: number, persist?: boolean): void;
delete(key: string): void;
cleanup?(): void;
clear?(): void;
status?():
| {
entryCount?: number;
persistentCount?: number;
expirationCount?: number;
}
| undefined;
};
and:
type CacheEntry = {
value: any;
expiry?: number | undefined;
};
These two types, CustomCacheStorage
and CacheEntry
are also exported by the package.
All props are optional. All props can be used together in the Client.
Prop | Description | Example | React Client Component | Server Allowed |
---|---|---|---|---|
onSuccess |
A function that takes the resolved value of a resource (promise or React Context) and returns a ReactNode . When used, children must be a Usable (promise or React Context) or a function returning a promise. |
<Suspense => values.map(item => <div key={item}>{item}</div>)}>{new Promise<string[]>(resolve => setTimeout(() => resolve(["Roger", "Alex"]), 1000))}</Suspense> |
❌ | ✅ |
onError |
A function that takes an Error and returns a ReactNode |
<Suspense => <div>{error.message}</div>} /> |
✅ | ❌ |
children |
A function that returns a promise, a ReactNode , or a Usable (promise or React Context). Specifies the resource or content that suspenses |
<Suspense>{new Promise<string>(r => setTimeout(() => r("Done"), 1000))}</Suspense> |
✅ when is a function ❌ rest of cases | ❌ when is a function ✅ rest of cases |
fallback |
A ReactNode |
<Suspense fallback="Loading..." /> |
❌ | ✅ |
retry |
A boolean that indicates whether to retry failing promises or not (default false ). When used, the resource should be a function returning a promise. |
<Suspense retry>{() => new Promise((resolve, reject) => setTimeout(() => {if(Math.random() > 0.6){resolve(["Roger", "Alex"])}else{reject("Failed")}}, 1000))}</Suspense> |
✅ | ❌ because of used with children as a function |
retryCount |
The number of retries to try when using retry (defaults to 1 ). Only applies when retry is used |
<Suspense retry retryCount={15}>{...}</Suspense> |
✅ because of used with retry |
❌ because of used with children as a function |
retryDelay |
The number of ms to apply (wait) between each retry (defaults to 0 ). Only applies when retry is used |
<Suspense retry retryDelay={1000}>{...}</Suspense> |
✅ because of used with retry |
❌ because of used with children as a function |
retryBackoff |
Specifies the backoff strategy to apply for retries (defaults undefined ). Can be "linear" , "exponential" , or a function with signature (attemptIndex: number, retryDelay: number) => number . Only applies when retry is used. |
<Suspense retry retryBackoff="linear">{...}</Suspense> |
✅ because of used with retry |
❌ because of used with children as a function |
onRetryFallback |
A function (with signature (retryAttempt: number) => ReactNode ) that specifies the fallback UI to be shown on each retry attempt. Only applies when retry is used. |
<Suspense retry => <div>Retrying {retryAttempt}...</div>}>{...}</Suspense> |
✅ because of used with retry |
❌ because of used with children as a function |
resourceId |
A string that identifies the resource being used. When used with cache serves as the cache key. Stabilizes the resource between rerenders. |
<Suspense resourceId="my-promise">{new Promise<string>(r => setTimeout(() => r("Done"), 1000))}</Suspense> |
✅ | ✅ if no props as function (e.g. ❌ with onSuccess ) |
cache |
A boolean that indicates whether to use the cache functionality or not. In order to work needs to be accompanied by resourceId , that serves as the cache key. |
<Suspense cache cachePersist resourceId="my-promise">{new Promise<string>(r => setTimeout(() => r("Done"), 1000))}</Suspense> |
✅ | ✅ if no props as function (e.g. ❌ with onSuccess ) |
cacheTTL |
A number that sets an expiration time in milliseconds for the cached value. Only applies when cache and resourceId are used. |
<Suspense cache cachePersist cacheTTL={60000} resourceId="my-promise">{...}</Suspense> |
✅ because of used with cache and resourceId |
✅ if no props as function (e.g. ❌ with onSuccess ) |
cacheVersion |
A number . Invalidates previous cached value when it's increased or decreased (changed). Only applies when cache and resourceId are used. |
<Suspense cache cachePersist cacheTTL={60000} cacheVersion={cacheVersionKey} resourceId="my-promise">{...}</Suspense> |
✅ because of used with cache and resourceId |
✅ if no props as function (e.g. ❌ with onSuccess ) |
cachePersist |
A boolean (defaults to false ) that indicates whether to persist the cached value into localStorage (or custom storage) or not. Only applies when cache and resourceId are used. |
<Suspense cache cachePersist cacheTTL={60000} cacheVersion={cacheVersionKey} resourceId="my-promise">{...}</Suspense> |
✅ because of used with cache and resourceId |
✅ if no props as function (e.g. ❌ with onSuccess ) |
timeouts |
A number[] . Timeouts in milliseconds to update fallback UI shown (onRetryFallback takes precedence). Only makes sense if timeoutFallbacks is used. |
<Suspense timeouts={[1000, 2000]} timeoutFallbacks={["Two...", "One..."]} fallback="Loading...">{new Promise<string>(r => setTimeout(() => r("Go!"), 3000))}</Suspense> |
✅ | ✅ if no props as function (e.g. ❌ with onSuccess ) |
timeoutFallbacks |
A ReactNode[] . Fallback UIs to show after each timeout specified in timeouts . Only makes sense if timeouts is not an empty array. |
<Suspense timeouts={[1000, 2000]} timeoutFallbacks={["Two...", "One..."]} fallback="Loading...">{new Promise<string>(r => setTimeout(() => r("Go!"), 3000))}</Suspense> |
✅ Because used with timeouts |
✅ if no props as function (e.g. ❌ with onSuccess ) |
productionPropsErrorFallback |
A ReactNode . Renders a custom fallback in production when invalid props are used in the Server (e.g. a React Client Component with props as functions in the Server). Defaults to null (renders nothing) if not provided. |
<Suspense productionPropsErrorFallback={<div>component not available - contact support</div>}>{()=>Promise.resolve("Hey")}</Suspense> |
❌ | ✅ |
- React 19.
This project is licensed under the MIT License.
Copyright (c) 2025 Roger Gomez Castells (@roggc)
See the LICENSE file for details.