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

Starting queries

prefetch, ensure and fetch: what each returns, what it waits for, and how to choose between granular skeletons and a blocked navigation.

Queries start where the page's inputs are known: a route loader, a server component, or an event handler. All three verbs live on the query client and take a factory plus its input. They differ in what they return and what they wait for:

CallReturnsCache missStale dataTanStack Query
qc.prefetch(factory, input)QueryHandleStarts the requestRefetches in the backgroundprefetchQuery, not awaited
qc.ensure(factory, input)Promise<QueryHandleWithData>Waits for the requestResolves at once, refetches in the backgroundensureQueryData({ revalidateIfStale: true })
qc.fetch(factory, input)Promise<QueryHandleWithData>Waits for the requestWaits for the refetchfetchQuery

Fresh cached data is reused by all three, without a request. So prefetch never holds anything up, ensure holds up only the first visit (returning to a page shows the cached data at once, then fresh data), and fetch always returns fresh data, at the cost of a round trip whenever the cache is stale. That difference needs a client that keeps its cache between navigations, as a TanStack Router loader's or a client-only React Router clientLoader's does. Code that only runs on the server has none: see Server-only code.

ensure and fetch return the same thing: the handle prefetch returns, plus the data they waited for. See Handles.

src/routes/posts.$slug.tsx
export const 
const Route: Route<Register, Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
    sort: ZodDefault<ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 15 more ..., undefined>
Route
=
createFileRoute<"/posts/$slug", Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
    sort: ZodDefault<ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, "/posts/$slug", "/$slug", "/posts/$slug">(path?: "/posts/$slug" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<...> & ... 1 more ... & UpdatableRouteOptions<...>) | undefined) => Route<...>
createFileRoute
("/posts/$slug")({
FilebaseRouteOptionsInterface<Register, Route<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 15 more ..., undefined>, ... 12 more ..., undefined>.loader?: Constrain<(args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
    sort: ZodDefault<ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, ... 8 more ..., undefined>, ... 7 more ..., undefined>) => Promise<...>, RouteLoaderFn<...> | RouteLoaderObject<...>> | undefined
loader
: async (
args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
    sort: ZodDefault<ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>
args
) => {
const const qc: QueryClient<never>qc =
args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
    sort: ZodDefault<ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>
args
.
LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 15 more ..., undefined>, ... 7 more ..., undefined>.context: {
    queryClient: QueryClient;
}
context
.queryClient: QueryClient<never>queryClient;
const { const slug: stringslug } =
args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
    sort: ZodDefault<ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>
args
.
LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 15 more ..., undefined>, ... 7 more ..., undefined>.params: {
    slug: string;
}
params
;
// Never waits: the comments load while the page renders. const
const comments: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
comments
= const qc: QueryClient<never>qc.
QueryClient<never>.prefetch<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>(factory: (input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>, input: {
    slug: string;
}): QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
prefetch
(
function commentsQuery(input: {
    slug: string;
}): InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>
commentsQuery
, { slug: stringslug });
// Waits only if the post isn't in the cache yet. const
const post: QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
= await const qc: QueryClient<never>qc.
QueryClient<never>.ensure<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>(factory: (input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>, input: {
    slug: string;
}): Promise<QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>> (+1 overload)
ensure
(
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
, { slug: stringslug });
return {
comments: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
comments
,
post: QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
};
}, UpdatableRouteOptionsExtensions.component?: RouteComponent | undefinedcomponent: function PostPage(): React.JSX.ElementPostPage, });

prefetch or ensure?

Neither is faster: both start the request at the same moment. The difference is what the user sees while it loads:

  • prefetch navigates now. The new page renders at once, and each part still waiting for data shows its nearest Suspense fallback.
  • ensure navigates when the data is in. Until then the previous page stays on screen. In TanStack Router, a route's pendingComponent replaces it after pendingMs (1 second by default).

Choose per query: ensure what the page can't lay out without (the post itself), and prefetch the rest (comments, sidebars, counts).

Awaiting several queries

Await independent blocking queries together with allKeyed, which ships with raf-query. It's a ponyfill of the TC39 Promise.allKeyed proposal: a record of promises in, a promise of the same record out, rejecting on the first failure.

return await 
allKeyed<{
    post: Promise<QueryHandleWithData<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>>;
    author: Promise<QueryHandleWithData<(input: {
        id: string;
    }) => QueryOptions<Flat<{
        data: Author;
        key: readonly ["author", string];
    }>>>>;
}>(record: {
    post: Promise<QueryHandleWithData<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>>;
    author: Promise<QueryHandleWithData<(input: {
        id: string;
    }) => QueryOptions<Flat<{
        data: Author;
        key: readonly ["author", string];
    }>>>>;
}): Promise<...>
allKeyed
({
post: Promise<QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>>
post
: const qc: QueryClient<never>qc.
QueryClient<never>.ensure<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>(factory: (input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>, input: {
    slug: string;
}): Promise<QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>> (+1 overload)
ensure
(
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
, { slug: stringslug }),
author: Promise<QueryHandleWithData<(input: {
    id: string;
}) => QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>>>
author
: const qc: QueryClient<never>qc.
QueryClient<never>.ensure<(input: {
    id: string;
}) => QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>>(factory: (input: {
    id: string;
}) => QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>, input: {
    id: string;
}): Promise<QueryHandleWithData<(input: {
    id: string;
}) => QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>>> (+1 overload)
ensure
(
function authorQuery(input: {
    id: string;
}): QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>
authorQuery
, { id: stringid: const slug: stringslug }),
});

When to use fetch

fetch is ensure that also waits on stale data. Use it when the code that starts the query needs the data to be fresh, not just present:

  • a loader that must branch on the data, such as a redirect
  • outside React, in scripts and tests

In a browser loader, prefer ensure: a stale fetch blocks the navigation on the refetch, while a stale ensure renders the cached data at once and updates when the refetch lands. TanStack Router hides the difference while it still holds the route's last loader data: it renders that and reruns the loader in the background, unless the loader's staleReloadMode is "blocking", as in the demo. There, open a post, go back, and reopen it once its cache is stale.

Server-only code

React server components and React Router's loader run only on the server, for every navigation, and each request gets a new client. Its cache starts empty, so every query is a cache miss, and a server client never refetches data it already has (see Behavior). There, ensure and fetch wait for the same request every time, and returning to a page waits again.

Use prefetch and fetch in that code, not ensure: prefetch for what can stream in behind a Suspense fallback, fetch for what the page can't lay out without or the code needs to branch on. See React Server Components and React Router.

A client-only React Router app is different: its clientLoaders run in the browser on the tab's client, which keeps its cache between navigations, so there ensure holds up only the first visit, as in TanStack Router. See React Router.

Behavior

  • Every verb dedupes against the cache: a request already in flight for the entry is joined, not repeated.
  • Loaders run on every navigation (TanStack Router reruns them when its own staleTime has passed, 0 by default), so returning to a page shows what's cached at once and then fresh data. With defaultPreload: "intent", also set defaultPreloadStaleTime: 0: otherwise the router reuses a hover's preload for 30 seconds instead of running the loader on click, and the query cache never hears about the navigation.
  • On the server, cached data is always reused, whatever staleTime says. Entries marked by invalidate still refetch.
  • A browser-only query never runs on the server: use prefetch there, not ensure or fetch.
  • A failed prefetch never throws out of the loader. The error surfaces where the data is read, and is reported to the client's onError.
  • A failed ensure or fetch rejects, so it reaches the route's error boundary.

Preloading on intent

Outside loaders, call the client directly. Starting a query when the pointer enters a link means the click finds it cached or in flight:

<const Link: LinkComponent<"a", string>Link
	RequiredToOptions<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, { queryClient: QueryClient; }, AnyContext, AnyContext, {}, undefined, ... 4 more ..., undefined>, "never", false, RouterHistory, Record<...>>, string, "/posts/$slug">.to: "." | ".." | "/posts/$slug" | "/" | "/login" | "/posts" | "/settings" | "/todos" | "/dashboard" | "/blog/$slug"to="/posts/$slug"
	
MakeRequiredPathParams<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, { queryClient: QueryClient; }, AnyContext, AnyContext, {}, ... 5 more ..., undefined>, "never", false, RouterHistory, Record<...>>, string, "/posts/$slug">.params: {
    slug: string;
} | ParamsReducerFn<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>, "PATH", string, "/posts/$slug">
params
={{ slug: stringslug }}
onPointerEnter?: React.PointerEventHandler<HTMLAnchorElement> | undefinedonPointerEnter={() => const qc: QueryClient<never>qc.
QueryClient<never>.prefetch<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>(factory: (input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>, input: {
    slug: string;
}): QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
prefetch
(
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
, { slug: stringslug })}
> {const title: stringtitle} </const Link: LinkComponent<"a", string>Link>;

With TanStack Router's defaultPreload: "intent" you get this for free: hovering a link runs its loader, which starts its queries.

Paged factories accept a third argument on ensure and fetch: { pages: n } loads the first n pages up front. See Infinite queries.