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(onlyfallbackand/orchildrenprops) - Promise Resolution Handling With
onSuccess - Error Handling With
onError(*RCC Only) - Retry Functionality With
retry(*RCC Only) - Caching With
cacheandresourceId - Timeout Fallbacks With
timeoutsAndtimeoutFallbacks - All Props Can Be Used Together In RCC (React Client Components)
- Invalid Combination Of Props In RSC (React Server Components)
children, The Resource Prop- The
resourceIdProp - The
cacheAPI - Props Reference
- Requirements
- License
To install the package, run:
npm i react-enhanced-suspenseYou 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
onSuccessto transform resolved promise or React Context values. -
(*)Error Handling: Use
onErrorto wrap React'sSuspensein anErrorBoundaryfor custom error rendering (RCC only). -
(*)Retry Functionality: Automatically retry failed promises with
retry, configurable withretryCount,retryDelay,retryBackoff, andonRetryFallback(RCC 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
timeoutsandtimeoutFallbacksfor long-running operations. -
React's
Suspense: This component is React'sSuspensewhen onlyfallbackorchildrenprops are used. -
TypeScript Support: Fixes TypeScript errors when using React Context with
Suspense, unlike React’s nativeSuspense.
(*): These props can only be used in React Client Components.
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 RCC or RSC.
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 RCC or RSC.
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 RCC.
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 RCC.
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 RCC or RSC.
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 RCC or RSC.
"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 a RSC, there are certain combination of props which are not allowed. This happens when the component itself (EnhancedSuspense) is a RCC and has props which are functions. For example, resourceId turns EnhancedSuspense into a Client Component. If we use then onSuccess, this is not allowed in a RSC, because functions are not serializable.
// In a RSC
{
/* resourceId + onSuccess: ❌ BAD (EnhancedSuspense is a RCC + Function) */
}
<Suspense resourceId="my-resource" onSuccess={(data) => <div>{data}</div>}>
{promise}
</Suspense>;When this happens, that EnhancedSuspense is a RCC and is used in a RSC passing some prop as a function, the app will crash.
Other props that turns the EnhancedSuspense into a RCC, 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 EnhancedSuspense cannot be used in a RSC (because it's a RCC with props as functions).
But a RCC can be used in a RSC when no props are functions. For example, this is allowed in a RSC:
// In a RSC
{
/* timeouts: ✅ EnhancedSuspense is a RCC, no props as functions */
}
<Suspense
fallback="Loading..."
timeouts={[3000, 6000, 10000]}
timeoutFallbacks={[
<div>Timeout 1</div>,
<div>Timeout 2</div>,
<div>Timeout 3</div>,
]}
>
{promise}
</Suspense>;onSuccess doesn't turn EnhancedSuspense into a RCC. So, despite it is a function, it can be used in a RSC:
// In a RSC
{
/* onSuccess: ✅ EnhancedSuspense is a RSC */
}
<Suspense fallback="Loading..." onSuccess={(data) => <div>{data}</div>}>
{promise}
</Suspense>;cache can also be used in a RSC if children is not a function (remember that cache only works with resourceId):
// In a RSC
{
/* cache + resourceId: ✅ EnhancedSuspense is a RCC, 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 a RSC, and only in a RCC.
| Props | EnhancedSuspense is a RCC | Allowed in a RSC |
|---|---|---|
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 a RCC 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 EnhancedSuspense into a RCC, 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. In EnhancedSuspense you can use resourceId prop to memoize the resource, so 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 (no resourceId prop)
>
{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 (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 (resourceId changes)
>
{resource}
</Suspense>
</>
);
}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
>
{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 a RCC.
| Prop | Description | Example | EnhancedSuspense is a RCC | Allowed in a RSC |
|---|---|---|---|---|
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 onSuccess={(values) => 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 onError={(error) => <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 onRetryFallback={(retryAttempt) => <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) |
- React 19.
This project is licensed under the MIT License.
Copyright (c) 2025 Roger Gomez Castells (@roggc)
See the LICENSE file for details.