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:
| Call | Returns | Cache miss | Stale data | TanStack Query |
|---|---|---|---|---|
qc.prefetch(factory, input) | QueryHandle | Starts the request | Refetches in the background | prefetchQuery, not awaited |
qc.ensure(factory, input) | Promise<QueryHandleWithData> | Waits for the request | Resolves at once, refetches in the background | ensureQueryData({ revalidateIfStale: true }) |
qc.fetch(factory, input) | Promise<QueryHandleWithData> | Waits for the request | Waits for the refetch | fetchQuery |
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.
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:
prefetchnavigates now. The new page renders at once, and each part still waiting for data shows its nearest Suspense fallback.ensurenavigates when the data is in. Until then the previous page stays on screen. In TanStack Router, a route'spendingComponentreplaces it afterpendingMs(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
staleTimehas passed,0by default), so returning to a page shows what's cached at once and then fresh data. WithdefaultPreload: "intent", also setdefaultPreloadStaleTime: 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
staleTimesays. Entries marked byinvalidatestill refetch. - A browser-only query never runs on the server: use
prefetchthere, notensureorfetch. - A failed
prefetchnever throws out of the loader. The error surfaces where the data is read, and is reported to the client'sonError. - A failed
ensureorfetchrejects, 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.