react-promise-suspense review
react-promise-suspense 0.3.4 exports one function named `usePromise`. During render it calls a Promise-returning loader, stores that work in a module-global array, and throws the pending Promise so React Suspense displays its fallback. A retry returns the stored value or throws the stored error. Cache lookup deep-compares only the input array, not the loader function. The current release made inputs optional and revised its typed example, but our installed package check found no TypeScript declarations. This is a small Suspense cache, not a general server-state client.
react-promise-suspense 0.3.4 installed 2 packages in 0.6 seconds and bundled to 1.2 KB gzipped in our sandbox, but it had no types and keys its global cache without loader identity. Do not adopt it for new production data fetching; keep it only where small, client-only legacy use is already contained by tests.
We installed it
| Install | ✓ · 0.6s | 2 packages on disk · 1 MB |
| Import | ✓ | ESM import works · require() works · CommonJS package |
| Browser | 1.2 KB | gzipped (2.7 KB minified), bundled with esbuild |
| Types | — | no TypeScript types found |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does react-promise-suspense install cleanly?
Yes. In a fresh container with an empty cache, npm install react-promise-suspense finished in 0.6s, leaving 2 packages and 1 MB on disk. npm audit reported no known vulnerabilities.
How much does react-promise-suspense add to a browser bundle?
1.2 KB gzipped (2.7 KB minified) when the whole package is bundled for the browser with esbuild. Importing only part of it is usually smaller.
Does react-promise-suspense work with both ESM and CommonJS?
Yes. Both import 'react-promise-suspense' and require('react-promise-suspense') worked in Node 22 in our run. The package is published as CommonJS.
Does react-promise-suspense include TypeScript types?
No type declarations were found in our install, so TypeScript users need their own declarations.
react-promise-suspense or @tanstack/react-query: which should you use?
@tanstack/react-query: Choose it for production server state with explicit keys, invalidation, retries, mutation, cancellation, and cache controls. react-promise-suspense 0.3.4 installed 2 packages in 0.6 seconds and bundled to 1.2 KB gzipped in our sandbox, but it had no types and keys its global cache without loader identity.
When should you not use react-promise-suspense?
You are choosing data fetching for a new production app. TanStack Query and SWR supply invalidation, retries, cancellation, mutations, and maintained React integration.
Use it if
- You maintain existing components that already depend on `usePromise` and have tests for its global cache behavior.
- A client-only experiment reads a small fixed set of immutable resources behind React Suspense.
- Every loader can include its own stable resource tag in the input tuple to prevent cross-loader collisions.
- Time-based eviction is enough and you do not need cancellation, invalidation, mutation, retry, or request scoping.
- You are choosing data fetching for a new production app. TanStack Query and SWR supply invalidation, retries, cancellation, mutations, and maintained React integration.
- Server rendering handles private or per-user data. The cache is module-global with no request boundary, so entries can be reused across requests.
- Two loaders may receive deeply equal inputs. Version 0.3.4 ignores function identity, and the repository has an open issue for the resulting wrong-response collision.
- Keys are numerous or user-controlled. Default lifespan 0 retains settled values and errors indefinitely, with no public clear function or size bound.
- You need a tested and typed dependency. The package's test script is `exit 0`, and our installed 0.3.4 package contained no TypeScript declarations.
Setup reality
We installed react-promise-suspense 0.3.4 in a fresh Node 22 Bookworm sandbox. npm finished in 0.6 seconds and left 2 packages using 1 MB. The package was 28 KB unpacked, declared 1 direct dependency and no peers, and npm audit found 0 known vulnerabilities. It is CommonJS without an exports map; both require() and ESM import worked. Our package inspection found no TypeScript declarations.
There is no provider or config file, and React is not declared as a peer. Put every call under Suspense, and put rejected loaders under an Error Boundary. usePromise(loader, inputs, lifespan) spreads the input array into the loader. A fetch loader must check response.ok because fetch resolves for HTTP 404 and 500. The package catches a rejection into its cache, then React receives that stored error on the next render.
Cache identity is the risky part. Version 0.3.4 scans one module-global array and compares only inputs with fast-deep-equal; it never compares the loader. Put a module-level Symbol or other unique resource tag in each loader's inputs. Lifespan 0 means no eviction. A positive timer starts after settlement, and changing a refresh value creates another entry rather than clearing the previous one. Open issues request function-aware keys, manual removal, size limits, and unmount cleanup.
Our browser bundle measured 2.7 KB minified and 1.2 KB gzipped. The size is low, but the missing controls matter more than transfer cost. Deep comparison runs during render and linearly scans cached entries, so keep keys small and acyclic. Do not use the global cache for authenticated SSR. The repository's test command exits successfully without running tests, and 0.3.4 is still below 1.0.
Patterns
Read JSON below Suspense fetch-json
import {Suspense} from 'react';
import usePromise from 'react-promise-suspense';
const USER = Symbol('user');
async function loadUser(_kind, 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, id], 30_000);
return <h2>{user.name}</h2>;
}
export const Screen = ({id}) => (
<Suspense fallback={<p>Loading...</p>}><User id={id} /></Suspense>
);The Symbol separates this loader's cache entries because version 0.3.4 does not include loader identity in its lookup.
Keep key construction in one helper wrap-resource
const PROJECT = Symbol('project');
async function fetchProject(_kind, id) {
const response = await fetch(`/api/projects/${id}`);
if (!response.ok) throw new Error('Project request failed');
return response.json();
}
export function useProject(id) {
return usePromise(fetchProject, [PROJECT, id], 60_000);
}Define the Symbol at module scope. Creating it during render makes every input tuple unique and can suspend on every retry.
Evict a settled result after 60 seconds expire-cache-entry
const report = usePromise(
loadReport,
[REPORT, reportId],
60_000,
);The timer starts when the Promise settles. Passing 0 or omitting the lifespan schedules no eviction.
Retain one immutable public value cache-fixed-resource
const CONFIG = Symbol('public-config');
const loadConfig = async () => {
const response = await fetch('/public-config.json');
if (!response.ok) throw new Error('Config unavailable');
return response.json();
};
const config = usePromise(loadConfig, [CONFIG]);No lifespan keeps the entry for the module's lifetime. Limit this pattern to a finite set of public immutable resources.
Create a fresh key on demand refresh-with-version
function Report({id}) {
const [revision, refresh] = useReducer(value => value + 1, 0);
const report = usePromise(loadReport, [REPORT, id, revision], 30_000);
return <button onClick={refresh}>Refresh {report.title}</button>;
}A new revision adds a cache entry; it does not delete the old one. Give old entries a finite lifespan.
Group parallel requests under one cache key load-related-data
const DASHBOARD = Symbol('dashboard');
async function loadDashboard(_kind, accountId) {
const [account, alerts] = await Promise.all([
fetch(`/api/accounts/${accountId}`).then(checkJson),
fetch(`/api/accounts/${accountId}/alerts`).then(checkJson),
]);
return {account, alerts};
}
const data = usePromise(loadDashboard, [DASHBOARD, accountId], 15_000);One rejected request caches an error for the combined resource. The package has no partial result or per-request retry state.
Turn bad HTTP status into an error reject-http-errors
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. Throwing sends the stored failure to the nearest Error Boundary on React's retry.
Pair Suspense with an Error Boundary catch-loader-error
class RequestBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) { return {error}; }
render() {
return this.state.error ? <p>Could not load.</p> : this.props.children;
}
}
<RequestBoundary>
<Suspense fallback={<Spinner />}><User id={userId} /></Suspense>
</RequestBoundary>Suspense handles the pending Promise. A rejected loader becomes a cached error and needs an Error Boundary.
Keep deep-comparison keys small use-small-inputs
const result = usePromise(
search,
[SEARCH, query, page, sortOrder],
10_000,
);Every render scans the global cache and deep-compares inputs. Avoid cyclic objects, DOM nodes, and large mutable structures.
Give two loaders separate resource tags avoid-loader-collision
const USER = Symbol('user');
const TEAM = Symbol('team');
const user = usePromise(loadUser, [USER, id], 30_000);
const team = usePromise(loadTeam, [TEAM, id], 30_000);Without `USER` and `TEAM`, equal `[id]` arrays can return whichever loader populated the cache first.
Let page sections suspend independently split-boundaries
<main>
<Suspense fallback={<ProfileSkeleton />}>
<Profile id={userId} />
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<Feed id={userId} />
</Suspense>
</main>Separate boundaries prevent one pending section from replacing the other section with the same fallback.
Keep it out of authenticated SSR isolate-server-requests
// Do not call usePromise for per-user data during shared-process SSR.
// Fetch on the server with a request-scoped cache, then pass the result
// to the client or use a framework data API with documented isolation.The cache is a module-global array and exposes no request scope or clear method, so private results can survive beyond one server request.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @tanstack/react-query | npm | Choose it for production server state with explicit keys, invalidation, retries, mutation, cancellation, and cache controls. |
| swr | npm | Choose it for a smaller maintained cache with revalidation, mutation, and focus-aware refresh behavior. |
| use-async-resource | npm | Choose it when a Suspense resource abstraction is the goal and its cache lifecycle fits your app. |
| react-async | npm | Choose it for explicit pending, fulfilled, and rejected state without relying only on thrown Promises. |
More web frontend guides
postcss · react · react-dom · tailwindcss · htmlparser2 · tailwind-merge · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

