Pull-to-refresh on a Suspense infinite feed flashes the whole skeleton
What you'll build: a useSuspenseInfiniteQuery feed whose initial load uses Suspense/ErrorBoundary cleanly, while refresh/invalidation keeps the current list on screen via startTransition — plus QueryErrorResetBoundary so Retry actually retries.
The scenario
A news feed uses useSuspenseInfiniteQuery behind <Suspense fallback={<FeedSkeleton />}>. First paint: skeleton → feed. Good. A "Refresh" button calls queryClient.invalidateQueries({ queryKey: ['feed'] }) (or refetchQueries). Because the update suspends again without a transition, React hides the existing feed and shows the skeleton for the whole refetch — including when 40 items were already on screen. Users call it "the app blinks."
Worse: after a network error, ErrorBoundary shows Retry; Retry only remounts the boundary. Query still has the error cached → immediate rethrow → Retry appears broken until a full reload. Missing QueryErrorResetBoundary.
Why it escaped QA: refresh tested on empty/slow first load (skeleton expected); error retry clicked once after DevTools "clear cache"; transitions never mentioned in the Suspense+Query copy-paste.
Walkthrough
Stage 1 — Name it: Suspense updates need transitions
The transition interplay: suspending updates default to showing the fallback. Wrap the refresh in startTransition so React holds the previous UI and surfaces pending instead. Initial mount still uses the skeleton — correct.
Stage 2 — Reject "just use non-Suspense infinite"
You can — useInfiniteQuery + local isPending — but then you lose the declarative boundary loading model. The bug is the refresh path, not Suspense itself.
Stage 3 — Suspense infinite + transition refresh + error reset
import { Suspense, useTransition } from "react";
import { useSuspenseInfiniteQuery, useQueryClient, QueryErrorResetBoundary } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";
function FeedList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useSuspenseInfiniteQuery({
queryKey: ["feed", "home"],
queryFn: fetchFeedPage,
initialPageParam: 1,
getNextPageParam: (last, all) => (last.length === 0 ? undefined : all.length + 1),
maxPages: 3,
});
return (
<>
{data.pages.flat().map((item) => (
<FeedCard key={item.id} item={item} />
))}
{hasNextPage && (
<button type="button" disabled={isFetchingNextPage} onClick={() => void fetchNextPage()}>
{isFetchingNextPage ? "Loading…" : "More"}
</button>
)}
</>
);
}
function RefreshButton() {
const qc = useQueryClient();
const [isPending, startTransition] = useTransition();
return (
<button
type="button"
disabled={isPending}
onClick={() => {
startTransition(() => {
void qc.refetchQueries({ queryKey: ["feed", "home"] });
});
}}
>
{isPending ? "Refreshing…" : "Refresh"}
</button>
);
}
export function FeedPage() {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary, error }) => (
<div role="alert">
<p>{error.message}</p>
<button type="button" onClick={resetErrorBoundary}>
Retry
</button>
</div>
)}
>
<RefreshButton />
<Suspense fallback={<FeedSkeleton />}>
<FeedList />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}Note: wire onReset={reset} from QueryErrorResetBoundary so Retry clears Query's error state (article Suspense block).
Stage 4 — Harden + verify the loop
fetchNextPageshould not blank the list —isFetchingNextPageis local; don't put next-page fetches in a transition that remounts the boundary incorrectly.- Prefer
refetchQueriesin a transition overresetQueriesif you want to keep showing data.
Verify the loop. Load feed → Refresh: skeleton does not replace the list; pending on the button. Throttle offline → error UI → Retry: one new GET, feed returns. Without reset wiring: Retry loops the error (reproduce once to confirm the bug).
Variations
- Pull-to-refresh library — call the same
startTransition+ refetch. invalidateQueriesin transition — same hold behavior.- Granular Suspense per section — refresh one column without blanking the chrome.
- Non-Suspense infinite — if the team won't adopt transitions yet.
- Router loader + dehydrate — first paint without client suspend; refresh still needs the transition discipline.
Trade-offs and common pitfalls
- Invalidate without transition — full skeleton flash.
- Retry without Query error reset — infinite error loop.
- Suspense around the whole app shell — refresh blanks nav too; tighten the boundary.
- Using
isPendingfrom Suspense query — not returned; use transition pending /isFetching. - Resetting queries on every focus — fighting the cache.
- Forgetting
maxPages— RAM recipe still applies. - ErrorBoundary inside Suspense — order is Error outside, Suspense inside (article).
- Testing refresh only on cold cache — miss the flash.
key={Date.now()}on feed to "force refresh" — nuclear remount; loses scroll.- Blocking next-page on transition — overkill; next page isn't a full resource remount.
When NOT to use Suspense infinite
If refresh/filter is constant and the team won't use transitions, plain useInfiniteQuery with isPending/isFetching is simpler. Suspense shines when boundaries already orchestrate loading/error for the route.
See also
References
- TanStack Query — Suspense
- TanStack Query — QueryErrorResetBoundary
Demo source
demos/data-fetching/suspense-infinite-refresh-blanks-feed/— refresh with/without transition + Retry reset. (Demo host TBD)