10LOC
Reactadvanced

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 per key: the first call kicks off fetchData() and stores a Resource in cache; every later call with the same key returns that same resource, request and all.
  • Internal state tracks the promise's outcome (pending / done / error) via the .then success 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: while state.status is "pending", it throws the in-flight promise, which Suspense catches; once it settles, read() either returns value or re-throws error for 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 calls read() 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.