suspend-react
suspend-react is a tiny React Suspense cache for promise-returning work. Call suspend during render with an async function and a key tuple; the first call throws the pending promise to the nearest Suspense boundary, a later render returns the resolved value directly, and rejected work reaches the nearest error boundary. The same module-global cache also supports preloading, peeking, manual clearing, expiring idle entries, and custom key equality. It is a low-level primitive, not a complete server-state or fetching library.
The implementation is admirably small, but the global cache and manual lifecycle make it best for narrow client-side resources with carefully designed keys. For application server state, TanStack Query or SWR earns its extra weight by handling invalidation, retries, cancellation, and cache boundaries.
Use it if
- You already understand React Suspense and need a very small read-through cache around promise-returning work
- Your data is naturally read during rendering and pending UI belongs at a parent Suspense boundary
- You can design collision-safe cache key tuples and explicitly clear or expire entries when data changes
- You want preload and peek helpers without adopting a full query-client configuration model
- You need retries, request cancellation, stale-while-revalidate, polling, mutations, optimistic updates, pagination, or network-status flags: the current source implements only promise caching, expiry, preload, peek, and clear
- You render unrelated users in one long-lived server process: the README says the cache is global, and the source stores entries in one module-level array with no request or provider boundary
- You cannot guarantee unique keys: the README warns that keys can bleed across callers and recommends a function name or Symbol suffix because the async function itself is not part of an explicitly supplied key tuple
- You need active React-version work and a settled API: the latest release is 0.1.3 from June 2023 and the repository's last push was October 2023
- You expect automatic recovery after a rejected request: the source stores the error on the cache entry and subsequent reads throw it again until clear removes that key or its lifespan timer evicts it
Setup reality
Install suspend-react alongside React. The current package metadata declares React >=17 as a peer dependency, even though the README still says the technique works from React 16.6, so dependency managers use 17 as the actual install constraint. Both ESM and CommonJS builds are published and TypeScript declarations are included. Every component that can suspend must render below a Suspense boundary, and rejected work needs an error boundary; Suspense does not catch errors. The central setup job is key design. Keys are arrays compared item by item with === by default, and the cache is one module-global array. Fresh object literals miss on every render unless you provide a custom equal function, while generic tuples such as [42] can collide across unrelated loaders because an explicitly supplied key replaces the function as identity. Append a stable string or Symbol for the resource type. The loader receives every key item as an argument, including that identity suffix, so write its parameter list accordingly or ignore the extra item. With no keys, the promise or function becomes the key. Entries live forever because lifespan defaults to 0; set a positive idle lifespan or call clear after mutations, logout, and tests. Reads refresh a positive timer. preload starts the same cache entry but returns undefined, peek returns a resolved value or undefined without suspending, and neither gives status metadata. Rejections are cached and thrown during future renders, so a retry button must clear the exact key before rendering again. There is no AbortSignal wiring, deduplication policy beyond exact key equality, server dehydration, provider-scoped cache, or automatic cleanup on unmount. In SSR, create process or request isolation outside this library or choose a query cache with explicit boundaries. The repository has not shipped since 2023, so test it against your exact React mode before committing architecture to it.
Patterns
Read async data under Suspensesuspend-data-read
import {Suspense} from 'react'
import {suspend} from 'suspend-react'
async function loadPost(id) {
const response = await fetch(`/api/posts/${id}`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
}
function Post({id}) {
const post = suspend(loadPost, [id, 'post'])
return <h1>{post.title}</h1>
}
export function Page({id}) {
return <Suspense fallback={<p>Loading...</p>}><Post id={id} /></Suspense>
}suspend returns the resolved value, not a promise. The loader receives both id and the identity suffix; extra arguments are harmless here.
Prevent key collisions with Symbolsisolate-resource-keys
import {suspend} from 'suspend-react'
const userResource = Symbol('user-resource')
const loadUser = (id) => fetch(`/api/users/${id}`).then((r) => r.json())
function User({id}) {
const user = suspend(loadUser, [id, userResource])
return <span>{user.name}</span>
}The cache is global and explicit keys do not automatically include loadUser. A stable Symbol keeps [1] for a user separate from [1] for another resource.
Start loading before renderpreload-resource
import {preload, suspend} from 'suspend-react'
const postResource = Symbol('post')
const loadPost = (id) => fetch(`/api/posts/${id}`).then((r) => r.json())
preload(loadPost, [42, postResource])
function Post() {
const post = suspend(loadPost, [42, postResource])
return <article>{post.title}</article>
}preload returns undefined. The later suspend call must use an equal key tuple to reuse the pending or resolved entry.
Read a resolved entry without suspendingpeek-cached-value
import {peek} from 'suspend-react'
const cached = peek([42, postResource])
if (cached !== undefined) {
console.log('already loaded', cached)
}peek returns only a resolved response or undefined. It does not distinguish missing, pending, rejected, or a legitimately resolved undefined value.
Invalidate one cache key after a mutationclear-one-entry
import {clear} from 'suspend-react'
async function savePost(id, input) {
await fetch(`/api/posts/${id}`, {
method: 'PUT',
headers: {'content-type': 'application/json'},
body: JSON.stringify(input)
})
clear([id, postResource])
}The key must match exactly, including the same Symbol or object references used for suspend. The next render starts a new load.
Reset the whole cacheclear-global-cache
import {clear} from 'suspend-react'
afterEach(() => {
clear()
})clear() removes every entry in the module-global cache. In an app, use this carefully because unrelated Suspense resources share that cache.
Expire an entry after inactivityexpire-idle-entry
const profile = suspend(loadProfile, [userId, profileResource], {
lifespan: 60_000
})lifespan is milliseconds and defaults to 0, which means keep forever. Each successful read refreshes a positive expiry timer.
Use a custom equality functioncompare-structured-keys
import deepEqual from 'fast-deep-equal'
import {suspend} from 'suspend-react'
const result = suspend(loadSearch, [{query, filters}, searchResource], {
equal: deepEqual
})fast-deep-equal is a separate dependency. Deep comparison runs while scanning cache entries, so primitive stable keys are cheaper and easier to invalidate.
Suspend directly on a stable promiseuse-promise-key
import {suspend} from 'suspend-react'
const settingsPromise = fetch('/api/settings').then((r) => r.json())
function Settings() {
const settings = suspend(settingsPromise)
return <pre>{JSON.stringify(settings, null, 2)}</pre>
}With no keys, the promise itself is the cache key. Create it outside render; a new promise on every render would create endless misses.
Use nested Suspense boundariesscope-loading-ui
function Dashboard() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<Summary />
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
</Suspense>
)
}A thrown pending promise is handled by the nearest Suspense boundary. Boundary placement determines how much already rendered UI is replaced.
Clear a failed entry before retryingretry-cached-error
import {startTransition} from 'react'
import {clear} from 'suspend-react'
function retryPost(id) {
clear([id, postResource])
startTransition(() => setRetryCount((count) => count + 1))
}Rejected entries stay cached and keep throwing. An error-boundary retry must clear the exact key before triggering another render.
Preload an entry with idle expirypreload-with-lifespan
preload(loadProduct, [sku, productResource], {lifespan: 30_000})
// Later, under Suspense:
const product = suspend(loadProduct, [sku, productResource], {lifespan: 30_000})Use the same key and lifecycle policy at preload and read sites. preload does not report pending or rejected status.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @tanstack/react-query | npm | You need a maintained server-state cache with retries, cancellation, invalidation, mutations, pagination, hydration, and optional Suspense integration |
| swr | npm | You want stale-while-revalidate fetching, focus revalidation, mutations, and a concise React hook API |
| react-async | npm | You prefer explicit pending, fulfilled, and rejected state through hooks or components instead of render-time promise throwing |