Skip to content
effect-api-query

Suspense and Error Recovery

Reset failed Suspense queries, preserve cached data, and start independent reads together.

Use TanStack’s native Suspense hooks inside an existing QueryClientProvider. For example, use the React client setup. useSuspenseQuery provides defined data after its loading boundary resolves. An initial failure reaches the nearest React error boundary after retries finish.

TanStack documents that cancellation does not work with useSuspenseQuery, useSuspenseQueries, or useSuspenseInfiniteQuery. Choose useQuery or useInfiniteQuery when your screen depends on cancellation, including stopping open stream work when observation ends. Generated signal forwarding does not remove this native hook limitation. See TanStack query cancellation.

This recipe uses a ready client for the RPC contract below. The application chooses which declared messages users can see. Error boundaries receive unknown. Before reading a domain message, recognize the execution wrapper and validate its Cause failure with Schema. Transport failures, defects, and unexpected errors use a generic fallback.

import {
QueryErrorResetBoundary,
useSuspenseQueries,
useSuspenseQuery,
} from '@tanstack/react-query'
import { createRpcQueryUtils, isEffectRpcQueryError } from '@ueberbrot/effect-api-query'
import type { InferQueryData, InferQueryError } from '@ueberbrot/effect-api-query'
import { Cause, Result, Schema } from 'effect'
import { Rpc, RpcGroup } from 'effect/rpc'
import type { RpcClient } from 'effect/rpc'
import { Component, Suspense } from 'react'
import type { ReactNode } from 'react'
export class UserUnavailable extends Schema.TaggedError<UserUnavailable>()('UserUnavailable', {
message: Schema.String,
}) {}
export const suspenseGroup = RpcGroup.make(
Rpc.make('users.get', {
payload: { id: Schema.Int },
success: Schema.Struct({ id: Schema.Int, name: Schema.String }),
error: UserUnavailable,
}),
)
export const makeSuspenseQueries = <E,>(
client: RpcClient.RpcClient.Flat<RpcGroup.Rpcs<typeof suspenseGroup>, E>,
) =>
createRpcQueryUtils<typeof suspenseGroup, readonly ['suspense-users'], E>(suspenseGroup, {
client,
keyPrefix: ['suspense-users'],
})
export type Queries<E> = ReturnType<typeof makeSuspenseQueries<E>>
export const failureMessage = (error: unknown): string => {
if (isEffectRpcQueryError(error)) {
const failure = Cause.findError(error.cause)
if (
error.cause.reasons.length === 1 &&
Result.isSuccess(failure) &&
Schema.is(UserUnavailable)(failure.success)
) {
return failure.success.message
}
}
return 'The request failed. Please try again.'
}

React owns the error boundary below. QueryErrorResetBoundary supplies its onReset callback. The retry button resets TanStack’s failed-query boundary, then clears React’s error state. The component can then mount and fetch again. The boundary uses React’s built-in Component class and requires no additional error-boundary package.

interface BoundaryProps {
readonly children: ReactNode
readonly onReset: () => void
}
interface BoundaryState {
readonly failed: boolean
readonly error: unknown
}
class RequestErrorBoundary extends Component<BoundaryProps, BoundaryState> {
override state: BoundaryState = { failed: false, error: undefined }
static getDerivedStateFromError(error: unknown): BoundaryState {
return { failed: true, error }
}
reset = () => {
this.props.onReset()
this.setState({ failed: false, error: undefined })
}
override render() {
if (this.state.failed) {
return (
<div role="alert">
<p>{failureMessage(this.state.error)}</p>
<button type="button" onClick={this.reset}>
Try again
</button>
</div>
)
}
return this.props.children
}
}

Use both boundaries around the component. This example disables automatic retries so the user can choose when to retry. Native Suspense behavior sends a failed initial load to the boundary. A failed background refetch keeps cached data visible and displays an inline error. The Refresh button retries that background request.

const UserDetails = <E,>({ rpc }: { readonly rpc: Queries<E> }) => {
const user = useSuspenseQuery(
rpc.users.get.queryOptions({ input: { id: 1 }, retry: false, staleTime: 30_000 }),
)
return (
<>
<h2>{user.data.name}</h2>
{user.error !== null && !user.isFetching ? (
<p role="alert">{failureMessage(user.error)}</p>
) : null}
<button type="button" onClick={() => void user.refetch()} disabled={user.isFetching}>
Refresh
</button>
</>
)
}
export const UserScreen = <E,>({ rpc }: { readonly rpc: Queries<E> }) => (
<QueryErrorResetBoundary>
{({ reset }) => (
<RequestErrorBoundary onReset={reset}>
<Suspense fallback={<p>Loading user…</p>}>
<UserDetails rpc={rpc} />
</Suspense>
</RequestErrorBoundary>
)}
</QueryErrorResetBoundary>
)

By default, Suspense throws query failures only when cached data is absent. If failed refetches must also reach the error boundary, throw the observer error from the component after fetching stops. When using a void contract, handle intentional null query data explicitly. See TanStack Suspense recovery.

Sequential useSuspenseQuery calls in one component can delay later reads. The first hook suspends before the later hook runs. To start independent requests together, pass them to useSuspenseQueries. Explicit query descriptors retain generated data and error types through TanStack’s parallel-hook overloads. Derive the descriptors from the leaf so contract changes update every consumer.

export const TwoUsers = <E,>({ rpc }: { readonly rpc: Queries<E> }) => {
type UserQuery = {
queryFnData: InferQueryData<typeof rpc.users.get>
error: InferQueryError<typeof rpc.users.get>
}
const [ada, grace] = useSuspenseQueries<[UserQuery, UserQuery]>({
queries: [
rpc.users.get.queryOptions({ input: { id: 1 }, retry: false, staleTime: 30_000 }),
rpc.users.get.queryOptions({ input: { id: 2 }, retry: false, staleTime: 30_000 }),
],
})
return (
<p>
{ada.data.name} and {grace.data.name}
</p>
)
}

Render TwoUsers inside the same reset and Suspense boundaries. Both reads start together. Choose a staleTime that keeps the earlier completed query fresh until the other finishes. Otherwise, mounting after Suspense resolves can cause an immediate refetch.