Suspense mode
useSuspenseQuery is a thin wrapper around useQuery that integrates with React Suspense:
- While the query is loading and has no data → throws the in-flight promise. React’s runtime treats this as “suspend this tree” and renders the nearest
<Suspense fallback>. - When the query fails → throws the error. The nearest
ErrorBoundarycatches it. - When the query resolves → returns the query object with
datatyped as the non-undefined success type.
Signature
function useSuspenseQuery<T>( options: AddQueryOptions<T>,): Query<T> & { data: T }Options are the same as useQuery. The return type adds the guarantee that data is the success type (not T | undefined).
Behavior
- Render-time cache init. Unlike
useQuery, the cache entry is created and the fetch is kicked off synchronously during render. This is necessary because a component that suspends from first render never commits itsuseEffect, so the effect-based init would never run. The init is idempotent — calling it twice for the same hashKey is a no-op. - Stable promise identity across renders. While the same query is pending, the hook throws the same promise reference each render so React can coalesce the suspension.
- Subscriptions still work post-resolve. After data lands, the hook subscribes via
useSyncExternalStoreso cache changes (refetch, invalidate, optimistic update) re-render the component normally.
Examples
Basic
import { Suspense } from "react";import { useSuspenseQuery } from "@mongez/atomic-query";
function UserList() { const q = useSuspenseQuery<User[]>({ queryKey: ["users"], queryFn: ({ signal }) => fetch("/api/users", { signal }).then(r => r.json()), }); // `q.data` is `User[]`, not `User[] | undefined`. return <ul>{q.data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;}
<Suspense fallback={<Spinner />}> <UserList /></Suspense>With ErrorBoundary
class ErrorBoundary extends React.Component<{ fallback: React.ReactNode }, { error: Error | null }> { state = { error: null as Error | null }; static getDerivedStateFromError(error: Error) { return { error }; } render() { if (this.state.error) return <>{this.props.fallback}</>; return this.props.children; }}
<ErrorBoundary fallback={<p>Failed to load.</p>}> <Suspense fallback={<Spinner />}> <UserList /> </Suspense></ErrorBoundary>The order matters: ErrorBoundary must be outside Suspense so it catches throws from the suspended subtree.
Multiple suspense queries — granular fallbacks
<Suspense fallback={<HeaderSkeleton />}> <Header /></Suspense>
<Suspense fallback={<FeedSkeleton />}> <Feed /></Suspense>Each Suspense boundary handles one query’s loading state. The header doesn’t wait for the feed.
Gotchas
- Render-time side effects. The hook creates the cache entry during render. This violates the usual React rule, but it’s idempotent and necessary for Suspense to work. Linters that flag this may need a suppression comment.
- No automatic ErrorBoundary. If the query errors and you don’t wrap in an
ErrorBoundary, the error bubbles up to the React root and crashes the tree. Always pair with anErrorBoundary. - The throw discards normal rendering. Anything you put before the throw (other hooks, derived computations) still runs. Anything after the throw doesn’t. If you have side effects to run on the data, put them in a
useEffectafter the throw — they’ll fire on the post-resolve render. - Initialization race. If two components mount with the same
queryKeyin the same render, both callinitSuspenseQuery, but the second call returns early because the entry already exists. Both share the same in-flight promise — same dedup mechanism asuseQuery.