Skip to content

raf-query is early. The API can still change between minor releases, so use it at your own risk.

raf-query

Guides

Retries and errors

Retry failed requests with counts, predicates or Schedules, choose delays, and show errors with error boundaries.

When a request fails in the browser, raf-query retries it 3 times, waiting 1, 2 and then 4 seconds, before treating the query as failed. Readers keep suspending, or keep showing their current data, the whole time.

export function 
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
(
input: {
    slug: string;
}
input
: { slug: stringslug: string }) {
return
queryOptions<Post, unknown, never, readonly ["post", string], undefined>(options: Omit<QueryOptions<{
    data: Post;
    error: unknown;
    key: readonly ["post", string];
    services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
queryOptions
({
queryKey: readonly ["post", string]queryKey: ["post",
input: {
    slug: string;
}
input
.slug: stringslug],
function queryFn(context: QueryFunctionContext): QueryFnResult<{
    data: Post;
    error: unknown;
    key: readonly ["post", string];
    services: never;
}>
queryFn
: () => function getPost(slug: string): Promise<Post>getPost(
input: {
    slug: string;
}
input
.slug: stringslug),
retry?: RetryPolicy | undefinedretry: 5, // or false, true, a predicate, or a Schedule }); }
retryRetries
3 (default in the browser)Up to that many times
0 or false (default on the server)Never
trueForever
(failureCount, error) => booleanWhile it returns true. failureCount starts at 1
A ScheduleAs the Effect schedule decides

Server renders never retry by default: the user is waiting for the HTML, and a failure can be retried by the browser instead.

A predicate is the place to skip retries for errors that won't go away:

queryOptions<Post, unknown, never, readonly ["post", "hello"], undefined>(options: Omit<QueryOptions<{
    data: Post;
    error: unknown;
    key: readonly ["post", "hello"];
    services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", "hello"];
}>>
queryOptions
({
queryKey: readonly ["post", "hello"]queryKey: ["post", "hello"],
function queryFn(context: QueryFunctionContext): QueryFnResult<{
    data: Post;
    error: unknown;
    key: readonly ["post", "hello"];
    services: never;
}>
queryFn
: () => function getPost(slug: string): Promise<Post>getPost("hello"),
retry?: RetryPolicy | undefinedretry: (failureCount: numberfailureCount, error: unknownerror) => failureCount: numberfailureCount <= 3 && !(error: unknownerror instanceof class HttpErrorHttpError && error: HttpErrorerror.HttpError.status: numberstatus === 404), });

Set it once for the app with config.defaults:

const const queryClient: QueryClient<never>queryClient = new new QueryClient<never>(config?: QueryClientConfig<never>): QueryClient<never>QueryClient({
	Config.defaults?: QueryBehavior | undefineddefaults: { QueryBehavior.retry?: RetryPolicy | undefinedretry: 1 },
});

Retry delay

retryDelay sets the wait before each retry. The default doubles from one second, up to 30 seconds. Pass a number for a constant delay, or a function:

queryOptions<Post, unknown, never, readonly ["post", "hello"], undefined>(options: Omit<QueryOptions<{
    data: Post;
    error: unknown;
    key: readonly ["post", "hello"];
    services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", "hello"];
}>>
queryOptions
({
queryKey: readonly ["post", "hello"]queryKey: ["post", "hello"],
function queryFn(context: QueryFunctionContext): QueryFnResult<{
    data: Post;
    error: unknown;
    key: readonly ["post", "hello"];
    services: never;
}>
queryFn
: () => function getPost(slug: string): Promise<Post>getPost("hello"),
// Constant retryDelay?: number | ((failureCount: number, error: unknown) => number) | undefinedretryDelay: 1000, });
queryOptions<Post, unknown, never, readonly ["post", "hello"], undefined>(options: Omit<QueryOptions<{
    data: Post;
    error: unknown;
    key: readonly ["post", "hello"];
    services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", "hello"];
}>>
queryOptions
({
queryKey: readonly ["post", "hello"]queryKey: ["post", "hello"],
function queryFn(context: QueryFunctionContext): QueryFnResult<{
    data: Post;
    error: unknown;
    key: readonly ["post", "hello"];
    services: never;
}>
queryFn
: () => function getPost(slug: string): Promise<Post>getPost("hello"),
// Exponential with a ceiling, the default's shape retryDelay?: number | ((failureCount: number, error: unknown) => number) | undefinedretryDelay: (failureCount: numberfailureCount) => var Math: MathMath.Math.min(...values: number[]): numbermin(1000 * 2 ** (failureCount: numberfailureCount - 1), 30_000), });

Retries while offline

In the browser, retries pause while navigator.onLine is false: after the delay, the next attempt waits for the window's online event. The request counts as fetching meanwhile. The first attempt always runs, since navigator.onLine can't be trusted to be right.

Showing errors

What the user sees depends on whether there was data before the failure.

The first load fails. The query's promise rejects, so use() throws to the nearest error boundary. Resetting that boundary re-renders the reader, which refetches the query:

<class CatchBoundaryCatchBoundary
	getResetKey: () => unknowngetResetKey={() => 
const post: {
    hash: string;
}
post
.hash: stringhash}
errorComponent?: ErrorRouteComponent | undefinederrorComponent={(props: ErrorComponentPropsprops) => ( <React.JSX.IntrinsicElements.div: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>div React.HTMLAttributes<HTMLDivElement>.role?: React.AriaRole | undefinedrole="alert"> <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p> Couldn't load comments:{" "} {props: ErrorComponentPropsprops.error: unknownerror instanceof var Error: ErrorConstructorError ? props: ErrorComponentPropsprops.error: Errorerror.Error.message: stringmessage :
var String: StringConstructor
(value?: any) => string
String
(props: ErrorComponentPropsprops.error: unknownerror)}
</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p> <React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button React.DOMAttributes<HTMLButtonElement>.onClick?: React.MouseEventHandler<HTMLButtonElement> | undefinedonClick={props: ErrorComponentPropsprops.reset: () => voidreset}>Try again</React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button> </React.JSX.IntrinsicElements.div: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>div> )} > <const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense React.SuspenseProps.fallback?: ReactNodefallback={<function CommentsSkeleton(): ReactNodeCommentsSkeleton />}> <
function Comments(props: {
    readonly handle: QueryHandle<typeof commentsQuery>;
}): ReactNode
Comments
handle: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
handle
={
const loaderData: {
    comments: QueryHandle<typeof commentsQuery>;
}
loaderData
.
comments: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
comments
} />
</const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense> </class CatchBoundaryCatchBoundary>

Any error boundary works: CatchBoundary from TanStack Router, react-error-boundary, Next.js's error.tsx, or your own class component.

A background refetch fails. The existing data stays on screen: losing it because a refetch failed would be worse than showing it a little longer. The error is on the reader's controls as error, for a hint:

const [const post: Postpost, 
const postState: QueryControls<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
postState
] =
useQuery<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>, undefined>(factory: (input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>, handle: QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>, options?: (Options & {
    readonly throwOnError?: never;
} & {
    readonly throwOnError?: undefined;
}) | undefined): readonly [data: Post, controls: QueryControls<...>] (+1 overload)
useQuery
(
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
,
const loaderData: {
    post: QueryHandleWithData<typeof postQuery>;
}
loaderData
.
post: QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
);
{
const postState: QueryControls<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
postState
.QueryControls<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>.error: unknownerror ? (
<React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p React.HTMLAttributes<HTMLParagraphElement>.role?: React.AriaRole | undefinedrole="status"> Couldn't refresh. Showing data from {function formatTime(value: number): stringformatTime(
const postState: QueryControls<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
postState
.QueryControls<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>.updatedAt: numberupdatedAt)}.
</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p> ) : null; }

Make the next request fail in the demo, then reload a post to see each case:

Errors from loaders

A failed prefetch never throws out of the loader: the error surfaces where the data is read, and only that part of the page shows it. A failed ensure or fetch rejects, so it reaches the route's error boundary (errorComponent in TanStack Router, error.tsx in Next.js), replacing the page.

This is one more reason to choose per query: ensure what the page can't do without, and prefetch the rest, so a failing sidebar doesn't take the whole page down.

One place for every error

config.onError is called for every failed request, after its retries, whether or not anything shows it. It's the place for logging and toasts:

const const queryClient: QueryClient<never>queryClient = new new QueryClient<never>(config?: QueryClientConfig<never>): QueryClient<never>QueryClient({
	QueryClientConfig<never>.onError?(error: unknown, query: Query): voidonError: (error: unknownerror, query: Query<QueryFactory>query) => {
		
function reportError(error: unknown, context: {
    readonly key: unknown;
}): void
reportError
(error: unknownerror, { key: unknownkey: query: Query<QueryFactory>query.Query<QueryFactory>.key: QueryKeykey });
}, });

Combine it with query meta for a per-query message.

Redirects

In TanStack Start, a queryFn can throw the router's redirect(), for instance when a session has expired. setupRouterIntegration turns it into a navigation wherever the query failed: a loader, a render or a background refetch. Redirects aren't retried.