Suspense mode passes an uncached promise to use(), triggering React's uncached-promise warning
Bug report
Description / Observed Behavior
In Suspense mode, useSWR calls use() with a promise that is re-created on every render (src/index/use-swr.ts#L875-L885):
const revalidation =
hasKeyButNoData && isUndefined(preloadData)
? revalidate(WITH_DEDUPE)
: resolvedUndef
if (!isUndefined(returnedData) && hasKeyButNoData) {
revalidation.status = 'fulfilled'
revalidation.value = true
}
use(revalidation)While there is no data this is a fresh pending promise returned by revalidate(); once data arrives it is the module-level resolvedUndef. React records the thenable passed to use() per hook index and errors when a re-render passes a different instance at the same index:
A component was suspended by an uncached promise. Creating promises inside a Client Component or hook
is not yet supported, except via a Suspense-compatible library or framework.React's current contract is that the promise handed to use() must be cached by the library ("except via a Suspense-compatible library or framework"), so this surfaces as an SWR-side warning.
Expected Behavior
No warning — the promise handed to use() should be stable across renders for the same key.
Repro Steps / Code Example
Two sibling useSWR(..., { suspense: true }) components under one <Suspense>, with fetchers that resolve immediately:
const A = () => <p>{useSWR('a', () => Promise.resolve('a'), { suspense: true }).data}</p>
const B = () => <p>{useSWR('b', () => Promise.resolve('b'), { suspense: true }).data}</p>
createRoot(el).render(
<Suspense fallback={<p>loading</p>}>
<A />
<B />
</Suspense>
)One uncached promise error is logged, for B.
Why only some hooks hit it
I instrumented react-dom-client.development.js (19.2.8) to log trackUsedThenable and which work-loop path each suspend takes. Trace for the repro above:
-- renderRootSync
track i=0 status=fulfilled <- use(resolvedUndef)
track i=1 status=pending <- use(revalidate(...)), A suspends
UNWIND A <- throwAndUnwindWorkLoop, resets thenableState
-- renderRootConcurrent
track i=0 status=fulfilled <- A re-renders, data present
track i=1 status=fulfilled
track i=0 status=fulfilled <- B mounts
track i=1 status=pending <- B suspends
-- renderRootConcurrent
REPLAY B <- replaySuspendedUnitOfWork, keeps thenableState
track i=0 prev=same
track i=1 prev=DIFFERENT <- warningThe deciding factor is which render pass the hook suspends in:
| Path | Handling of SuspendedOnImmediate |
thenableState |
Warning |
|---|---|---|---|
renderRootSync |
always throwAndUnwindWorkLoop |
reset to null |
no |
renderRootConcurrent |
SuspendedAndReadyToContinue, then replaySuspendedUnitOfWork if the thenable settled before the loop resumes |
retained | yes |
So a single suspense hook mounted on the initial (sync) render never warns — it always unwinds and re-renders with a fresh thenable state. A hook that first suspends inside a concurrent render, and whose promise settles before React resumes the yielded loop, hits the replay path and sees a different promise at the same index.
That makes it easy to hit in practice, because mounting inside a Suspense retry counts: in a Next.js 16 app with a suspense useSWR component rendered from another suspense useSWR component's data (both hitting real API routes), the child warned on roughly every other page load — 8 occurrences over 5 reloads.
Additional Context
- Reproduced on swr 2.4.2 and 2.5.0, react / react-dom 19.2.8.
- Development builds only — the warning does not exist in React's production build, so this is noise rather than broken behaviour.
- Caching the promise per key and reusing it until it settles removes the warning in my testing, but I have not checked that approach against the
preload/keepPreviousData/ RSCcacheDatapaths.
Source: vercel/swr