react-promise-suspense
react-promise-suspense exports one function, usePromise, that calls a Promise-returning function during React render and throws the pending Promise so the nearest Suspense boundary shows its fallback. When the work settles, React retries and the function returns the cached value or throws the cached error. Inputs are compared with fast-deep-equal and an optional lifespan removes a cache entry after settlement. Despite the hook-like name, the implementation uses no React hooks and has no React dependency or peer declaration.
Do not install react-promise-suspense for new production data fetching. Its tiny API is appealing, but function-blind cache keys, permanent default retention, global SSR state, and a no-op test suite make the simplicity unsafe beyond controlled legacy code.
Use it if
- You maintain an existing component that already uses this exact function and its global cache behavior is understood
- You need a tiny client-only Suspense proof of concept for a finite, low-cardinality set of immutable resources
- You can give every resource type a unique stable tag in its input array to avoid cross-loader cache collisions
- You accept a basic time-based cache with no invalidation, cancellation, mutation, retry, or request-scoping API
- You are choosing data fetching for a new application: TanStack Query or SWR provides invalidation, retries, deduplication, mutations, cancellation, developer tools, and documented React integration
- You render on a shared Node server: the cache is a module-global array, the default lifespan never removes entries, and there is no request scope or public clear function
- Different Promise functions can receive equal argument arrays: version 0.3.4 compares only inputs, not function identity, so one loader can receive another loader's cached response; this remains an open issue
- Your key space is large or user-controlled: there is no cache-size bound or LRU policy, and open issues request size limits, unmount cleanup, and programmatic removal
- You need production confidence: the package is below 1.0, its test script is literally exit 0, the README has one main example, and the last published code change was in February 2023
Setup reality
npm install react-promise-suspense adds fast-deep-equal and no declared React peer dependency. The package is CommonJS with an export-equals TypeScript declaration; depending on compiler settings, TypeScript uses import usePromise = require('react-promise-suspense') or a default import with esModuleInterop. There is no provider or config file, but every caller must be below React Suspense and rejected work needs an Error Boundary. The loader must return a Promise and the input array is spread into it as arguments. The important setup is cache discipline. Version 0.3.4 searches one module-global array using only deep equality of inputs. It does not include the loader function in the key, even though an open issue identifies that bug. Give each resource family a module-level Symbol or other unique tag as its first input and make the loader accept that unused argument. Avoid cyclic input objects because the equality library is not a general cyclic-graph keyer, and prefer small primitive keys because every lookup scans the array. lifespan defaults to 0, which means no scheduled eviction; both responses and errors remain cached. A positive lifespan starts its timer only after the Promise settles. There is no manual invalidation, cache bound, focus refetch, stale-while-revalidate behavior, retry, cancellation, AbortController ownership, unmount cleanup, or mutation coordination. Changing an input forces a different entry but does not remove the old one until its own lifespan expires. fetch also resolves on HTTP 404 and 500, so loaders must check response.ok if those should reach an Error Boundary. Server rendering is especially risky because cached values are shared across requests and users. The package has no request-scoped cache or reset API, so do not use it for authenticated SSR data.
Patterns
Read JSON under a Suspense boundaryfetch-json
import { Suspense } from 'react';
import usePromise from 'react-promise-suspense';
const USER_RESOURCE = Symbol('user');
const loadUser = async (_resource, id) => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
};
function User({ id }) {
const user = usePromise(loadUser, [USER_RESOURCE, id], 30_000);
return <h2>{user.name}</h2>;
}
export function Screen({ id }) {
return <Suspense fallback={<p>Loading...</p>}><User id={id} /></Suspense>;
}The Symbol distinguishes this loader from every other loader because version 0.3.4 does not include function identity in its cache key.
Hide cache-key discipline in a resource hookdefine-resource-hook
const PROJECT_RESOURCE = Symbol('project');
async function fetchProject(_resource, projectId) {
const response = await fetch(`/api/projects/${projectId}`);
if (!response.ok) throw new Error('Project request failed');
return response.json();
}
export function useProject(projectId) {
return usePromise(fetchProject, [PROJECT_RESOURCE, projectId], 60_000);
}Keep the tag at module scope. Creating Symbol('project') inside the hook would produce a new cache key on every render and suspend forever.
Expire a settled entry after a minuteset-cache-lifespan
const result = usePromise(
loadReport,
[REPORT_RESOURCE, reportId],
60_000
);The eviction timer begins after the Promise settles, not when the request starts. A lifespan of 0 schedules no eviction.
Keep a finite immutable resource cachedcache-static-resource
const CONFIG_RESOURCE = Symbol('public-config');
const loadConfig = (_resource) =>
fetch('/public-config.json').then((response) => {
if (!response.ok) throw new Error('Config unavailable');
return response.json();
});
const config = usePromise(loadConfig, [CONFIG_RESOURCE]);Omitting lifespan retains this entry for the life of the module. Reserve that for a small fixed set of public immutable values.
Force a new request by changing an inputforce-refresh
function Report({ id }) {
const [revision, refresh] = useReducer((n) => n + 1, 0);
const report = usePromise(
loadReport,
[REPORT_RESOURCE, id, revision],
30_000
);
return <button onClick={refresh}>Refresh {report.title}</button>;
}This creates a new cache entry; it does not delete the old one. Use a finite lifespan to prevent refresh keys accumulating permanently.
Resolve related requests in parallelload-in-parallel
const DASHBOARD_RESOURCE = Symbol('dashboard');
async function loadDashboard(_resource, accountId) {
const [account, alerts] = await Promise.all([
fetch(`/api/accounts/${accountId}`).then(checkJson),
fetch(`/api/accounts/${accountId}/alerts`).then(checkJson),
]);
return { account, alerts };
}
const dashboard = usePromise(
loadDashboard,
[DASHBOARD_RESOURCE, accountId],
15_000
);One rejection caches the combined error until eviction. The library supplies no per-request retry or partial-result state.
Turn HTTP failures into rejected Promisescheck-http-status
async function checkJson(response) {
if (!response.ok) {
const error = new Error(`Request failed: ${response.status}`);
error.status = response.status;
throw error;
}
return response.json();
}fetch resolves normally for 404 and 500 responses. Throw explicitly so the cached error reaches an Error Boundary.
Catch rejected work with an Error Boundarycatch-render-errors
class RequestErrorBoundary extends React.Component {
state = { error: null };
static getDerivedStateFromError(error) { return { error }; }
render() {
if (this.state.error) return <p>Could not load this section.</p>;
return this.props.children;
}
}
<RequestErrorBoundary>
<Suspense fallback={<Spinner />}>
<User id={userId} />
</Suspense>
</RequestErrorBoundary>Suspense handles pending Promises, not rejected results. The error remains cached for that input until its lifespan expires.
Build cache inputs from small primitive valuesuse-primitive-inputs
const searchResult = usePromise(
search,
[SEARCH_RESOURCE, query, page, sortOrder],
10_000
);Inputs are deep-compared by a linear scan of the global cache. Avoid cyclic objects, large structures, DOM nodes, and freshly changing values.
Infer a typed result from a typed loadertype-result
type User = { id: string; name: string };
const USER_RESOURCE = Symbol('user');
async function loadUser(_resource: symbol, id: string): Promise<User> {
return checkJson(await fetch(`/api/users/${id}`));
}
const user = usePromise(loadUser, [USER_RESOURCE, userId], 30_000);
user.name;The 0.3.4 declaration uses tuple generics, so argument and result types flow from the loader. The package uses a CommonJS export-equals declaration.
Import without esModuleInteropimport-with-typescript
import usePromise = require('react-promise-suspense');With esModuleInterop enabled, a default import normally works. The published declaration uses export = usePromise.
Keep independent sections from blocking togethersplit-suspense-boundaries
<main>
<Suspense fallback={<ProfileSkeleton />}>
<Profile id={userId} />
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<Feed id={userId} />
</Suspense>
</main>Separate boundaries let one cached or slow resource resolve without forcing the other section to share its fallback.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @tanstack/react-query | npm | Use it for production server-state fetching with explicit keys, retries, invalidation, mutations, cancellation, and cache controls. |
| swr | npm | Use it for a smaller stale-while-revalidate model with focus revalidation, mutation, and a maintained React cache API. |
| use-async-resource | npm | Use it when you specifically want a Suspense resource abstraction and are prepared to evaluate its cache model. |