Suspense data fetching with a thrown promise cache
Published March 9, 2027
type Resource<T> = { read: () => T };
const cache = new Map<string, Resource<unknown>>();
export const getResource = <T,>(key: string, fetchData: () => Promise<T>): Resource<T> => {
if (cache.has(key)) return cache.get(key) as Resource<T>;
let state: { status: "pending" } | { status: "done"; value: T } | { status: "error"; error: unknown } = {
status: "pending",
};
const promise = fetchData().then(
(value) => { state = { status: "done", value }; },
(error) => { state = { status: "error", error }; }
);
const resource: Resource<T> = {
read: () => {
if (state.status === "pending") throw promise;
if (state.status === "error") throw state.error;
return state.value;
},
};
cache.set(key, resource);
return resource;
};What
A "resource" wraps a promise so that reading it (resource.read()) throws the promise while it's pending, throws the error if it rejected, or returns the value if it resolved — the exact contract Suspense needs to show a fallback and later the real content.
Why it matters
Suspense's mechanism has always been "a descendant threw a promise during render; catch it, show the fallback, and try again once it resolves." That's true whether the thrower is React.lazy, the built-in use() hook, or your own code — this snippet is what use() does for you today, made explicit. The reason it needs a cache at all: a component can't just call fetch() and throw the result directly, because every re-render (including the one Suspense triggers to retry) would kick off a new request and throw a new promise, forever. The cache is what makes "throw, wait, retry" converge instead of loop.
How it works
getResource(key, fetchData)is idempotent perkey: the first call kicks offfetchData()and stores aResourceincache; every later call with the samekeyreturns that same resource, request and all.- Internal
statetracks the promise's outcome (pending/done/error) via the.thensuccess and failure callbacks, since a promise itself can't be inspected synchronously for its current status. resource.read()is what a component calls during render: whilestate.statusis"pending", it throws the in-flightpromise, whichSuspensecatches; once it settles,read()either returnsvalueor re-throwserrorfor the nearest error boundary to catch.- Because the same resource object is returned on every call for a given
key, React's retry after the promise resolves callsread()again and gets the now-resolved value instead of starting over.
Gotchas
- The cache never evicts anything here — fine for a handful of keys known ahead of time, not fine for keys derived from unbounded user input without adding expiry or an LRU limit. Keeping every branch of the pending/done/error handling intact is why this snippet keeps
lineWaiver: true— this is the exact "exhaustive Suspense fetching" case that's dishonest if trimmed. - This predates and is superseded by React's built-in
use(promise)for new code —use()needs the same "don't create a fresh promise every render" discipline, but handles the throw/catch/unwrap mechanics for you. - A rejected resource stays rejected forever under this exact implementation; a real cache typically deletes the entry on error so a subsequent render can retry the fetch.
Related
use()— the current, React-provided way to read a promise (or context) during render under Suspense; reach for it instead of hand-rolling this cache in new code.React.lazy— uses the identical suspend-on-throw mechanism, specialized for loading a component's module instead of arbitrary data.