Skip to content

Suspense Queries

React 18 introduced first-class support for Suspense and ErrorBoundary patterns. Arc provides Suspense-compatible variants of every query hook so components can declaratively express their loading and error states without managing isPerforming or hasExceptions flags manually.

Suspense-compatible hooks use React’s throw-a-Promise protocol:

  1. The hook is called inside a component tree wrapped in <Suspense> and a QueryErrorBoundary.
  2. While the query is in-flight the hook throws a Promise. React catches it, shows the <Suspense> fallback, and re-renders the component when the Promise resolves.
  3. On success the component renders normally, with the result already available.
  4. On failure the hook throws an error (QueryFailed or QueryUnauthorized). The nearest QueryErrorBoundary catches it and shows its fallback.

This means the component body contains only the happy-path rendering logic — loading and error handling are entirely declarative through the surrounding boundaries.

<QueryErrorBoundary onError={({ isQueryUnauthorized, reset }) =>
isQueryUnauthorized
? <p>Not authorized. <button onClick={reset}>Retry</button></p>
: <p>Something went wrong. <button onClick={reset}>Retry</button></p>
}>
<Suspense fallback={<Spinner />}>
<ItemList /> {/* suspends until data arrives */}
</Suspense>
</QueryErrorBoundary>

Note: Because these hooks suspend during loading, they must be rendered inside a <Suspense> boundary; otherwise React will throw an unhandled Promise to the root.

Arc ships two ready-to-use boundary components so you don’t need to write your own class-based ErrorBoundary.

A class-based error boundary that catches QueryFailed and QueryUnauthorized thrown by the Suspense query hooks. Use it when you want to control the <Suspense> and QueryErrorBoundary separately.

import { QueryErrorBoundary } from '@cratis/arc.react/queries';
<QueryErrorBoundary
onError={({ error, isQueryFailed, isQueryUnauthorized, reset }) => (
<div>
<p>{isQueryUnauthorized ? 'Not authorized' : 'Server error'}</p>
{isQueryFailed && <p>{error.exceptionMessages.join(', ')}</p>}
<button onClick={reset}>Retry</button>
</div>
)}
>
<Suspense fallback={<Spinner />}>
<ItemList />
</Suspense>
</QueryErrorBoundary>

Props:

PropTypeDescription
onError(info: QueryErrorInfo) => ReactNodeCalled when an error is caught. Return the fallback UI.
fallbackReactNodeStatic fallback to render when an error is caught. onError takes precedence if both are provided.
childrenReactNodeThe subtree to protect.

QueryErrorInfo:

PropertyTypeDescription
errorErrorThe raw error that was caught.
isQueryFailedbooleantrue when the error is a QueryFailed.
isQueryUnauthorizedbooleantrue when the error is a QueryUnauthorized.
reset()() => voidResets the boundary so the child subtree is re-mounted.

A convenience wrapper that combines <Suspense> and QueryErrorBoundary in a single component. Use this for the common case where you want to handle both loading and error states together.

import { QueryBoundary } from '@cratis/arc.react/queries';
<QueryBoundary
loadingFallback={<Spinner />}
onError={({ isQueryUnauthorized, reset }) =>
isQueryUnauthorized
? <p>Not authorized. <button onClick={reset}>Retry</button></p>
: <p>Something went wrong. <button onClick={reset}>Retry</button></p>
}
>
<ItemList />
</QueryBoundary>

Props:

PropTypeDescription
loadingFallbackReactNodeRendered by the inner <Suspense> while the query is loading. Defaults to null.
onError(info: QueryErrorInfo) => ReactNodeCalled when an error is caught. Return the fallback UI.
fallbackReactNodeStatic error fallback. onError takes precedence if both are provided.
childrenReactNodeThe subtree to protect.

Use useSuspenseQuery for standard request/response HTTP queries.

import { useSuspenseQuery } from '@cratis/arc.react/queries';
function ItemList() {
const [result, perform] = useSuspenseQuery(AllItems);
return (
<ul>
{result.data.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
}

Signature:

useSuspenseQuery<TDataType, TQuery, TArguments>(
query: Constructor<TQuery>,
args?: TArguments,
sorting?: Sorting
): [QueryResultWithState<TDataType>, PerformQuery<TArguments>, SetSorting]
Return valueTypeDescription
resultQueryResultWithState<TDataType>The resolved query result. Never undefined when the component renders.
perform() => Promise<void>Re-runs the query. Clears the cache and triggers a new Suspense cycle.
setSorting(sorting: Sorting) => Promise<void>Changes the sort order and triggers a new Suspense cycle.
import { useSuspenseQueryWithPaging } from '@cratis/arc.react/queries';
import { Paging } from '@cratis/arc/queries';
function PagedList() {
const [result, perform, setSorting, setPage, setPageSize] =
useSuspenseQueryWithPaging(AllItems, new Paging(0, 20));
return (
<>
<ul>
{result.data.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
<button onClick={() => setPage(result.paging.page + 1)}>Next page</button>
</>
);
}

Use useSuspenseObservableQuery for WebSocket-based observable queries. The component suspends until the first message is received over the WebSocket, then re-renders reactively on every subsequent update without suspending again.

import { useSuspenseObservableQuery } from '@cratis/arc.react/queries';
function LiveFeed() {
const [result] = useSuspenseObservableQuery(LiveItems);
return (
<ul>
{result.data.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
}

Signature:

useSuspenseObservableQuery<TDataType, TQuery, TArguments>(
query: Constructor<TQuery>,
args?: TArguments,
sorting?: Sorting
): [QueryResultWithState<TDataType>, SetSorting]
Return valueTypeDescription
resultQueryResultWithState<TDataType>The latest result from the observable. Never undefined when the component renders.
setSorting(sorting: Sorting) => Promise<void>Changes the sort order, unsubscribes the old stream, and subscribes a new one.
import { useSuspenseObservableQueryWithPaging } from '@cratis/arc.react/queries';
import { Paging } from '@cratis/arc/queries';
function PagedFeed() {
const [result, setSorting, setPage, setPageSize] =
useSuspenseObservableQueryWithPaging(LiveItems, new Paging(0, 20));
// ...
}

When a query fails the hook throws one of two typed errors, both of which extend Error.

Thrown when the server returns hasExceptions: true. Carries the server-side exception details.

PropertyTypeDescription
exceptionMessagesstring[]One or more exception messages from the server.
exceptionStackTracestringThe full server-side stack trace.

Thrown when the server returns isAuthorized: false.

Generated query proxies expose useSuspense() and useSuspenseWithPaging() static methods that forward directly to the hooks above, giving the same ergonomics as the existing use() method.

// Regular query via proxy
function ItemList() {
const [result, perform] = AllItems.useSuspense();
return <ul>{result.data.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}
// With arguments
function FilteredList() {
const [result] = AllItems.useSuspense({ filter: 'active' });
return <ul>{result.data.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}
// Observable query via proxy
function LiveFeed() {
const [result] = LiveItems.useSuspense();
return <ul>{result.data.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}

The following example uses QueryBoundary — the simplest way to wrap a Suspense component in Arc.

import { QueryBoundary, QueryFailed } from '@cratis/arc.react/queries';
import { AllItems } from './generated/queries';
function ItemList() {
const [result, perform] = AllItems.useSuspense();
return (
<>
<ul>
{result.data.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
<button onClick={perform}>Refresh</button>
</>
);
}
export function App() {
return (
<QueryBoundary
loadingFallback={<p>Loading…</p>}
onError={({ error, isQueryFailed, isQueryUnauthorized, reset }) => (
<div>
{isQueryUnauthorized && <p>You are not authorized to view this data.</p>}
{isQueryFailed && (
<>
<p>Failed to load data:</p>
<ul>
{(error as QueryFailed).exceptionMessages.map((m, i) => (
<li key={i}>{m}</li>
))}
</ul>
</>
)}
<button onClick={reset}>Retry</button>
</div>
)}
>
<ItemList />
</QueryBoundary>
);
}
FeatureuseQuery / useObservableQueryuseSuspenseQuery / useSuspenseObservableQuery
Loading stateresult.isPerforming === trueComponent suspends; <Suspense fallback> renders
Error stateresult.hasExceptions === trueHook throws; QueryErrorBoundary catches
Authorization failureresult.isAuthorized === falseHook throws QueryUnauthorized; QueryErrorBoundary catches
Requires <Suspense> boundaryNoYes (or use QueryBoundary)
Requires error boundaryNo (optional)Strongly recommended (QueryErrorBoundary or QueryBoundary)
Re-run triggerperform() callbackperform() callback — clears cache, re-suspends
Proxy static method.use().useSuspense()

Suspense hooks maintain a module-level cache so the in-flight Promise and its resolved result survive React’s Suspense retry cycle on uncommitted components. When you call perform() (or setSorting() / setPage()) the cache entry is cleared and the component enters a new Suspense cycle.

Testing: Call clearSuspenseQueryCache() or clearSuspenseObservableQueryCache() in your test teardown to prevent state leaking between tests.

import { clearSuspenseQueryCache, clearSuspenseObservableQueryCache } from '@cratis/arc.react/queries';
afterEach(() => {
clearSuspenseQueryCache();
clearSuspenseObservableQueryCache();
});