Guides
Handles
The small, serializable object that carries a query from the loader or server to the component that reads it.
A handle is the only thing that crosses the loader → component or server → client boundary:
type Handle = interface QueryHandle<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandle<typeof function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery>;// { key: QueryKey; input: { slug: string } }
type WithData = interface QueryHandleWithData<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandleWithData<typeof function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery>;// { key: QueryKey; input: { slug: string }; data: Post }
type type PostInput = {
slug: string;
}
PostInput = type InputOf<TFactory extends QueryFactory> = Parameters<TFactory> extends [] ? undefined : Parameters<TFactory>[0]InputOf<typeof function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery>; // { slug: string }
type type PostKey = readonly unknown[]PostKey = type QueryKey = readonly unknown[]QueryKey; // readonly unknown[]Handles with data
prefetch doesn't wait, so its handle is the query's key and input. ensure and fetch waited for the data, so they return a QueryHandleWithData: the same handle plus data, for the code that called them. A loader can branch on it or build another query's input from it, and a server component can render it.
data is the cache entry's own object, not a copy. Data reaches the browser's cache through the hydration stream, and the TanStack Router serializer and React's RSC payload both send an object once, however many handles and entries point at it.
Typed by what the loader did
The loader's return value lists exactly which queries a route started, and its types show which ones the route waited for. useQuery returns the data for a QueryHandleWithData and a promise for a QueryHandle:
const const loaderData: {
comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>;
post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>;
}
loaderData = 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.RouteExtensions<"/posts/$slug", "/posts/$slug">.useLoaderData: <RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, RootRouteChildren, FileRouteTypes, unknown, unknown, undefined>, "never", false, RouterHistory, Record<string, any>>, unknown, boolean>(opts?: (UseLoaderDataBaseOptions<RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {
queryClient: QueryClient;
}, ... 8 more ..., undefined>, "never", false, RouterHistory, Record<...>>, "/posts/$slug", true, unknown, boolean> & OptionalStructuralSharing<...>) | undefined) => {
...;
}
useLoaderData();
const [post] = 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: {
comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>;
post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>;
}
loaderData.post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
post); // ensuredconst [commentsPromise] = useQuery<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>, undefined>(factory: (input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>, handle: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>, options?: (Options & ... 1 more ... & {
...;
}) | undefined): readonly [promise: ...] (+1 overload)
useQuery(function commentsQuery(input: {
slug: string;
}): InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>
commentsQuery, const loaderData: {
comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>;
post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>;
}
loaderData.comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>
comments); // prefetchedType a prop with the handle its loader returns:
import type { interface QueryHandle<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandle, interface QueryHandleWithData<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandleWithData } from "@raf-query/react";
// Fed by `prefetch`: `useQuery` returns a promise.
function function Comments(props: {
handle: QueryHandle<typeof commentsQuery>;
}): void
Comments(props: {
handle: QueryHandle<typeof commentsQuery>;
}
props: { handle: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>
handle: interface QueryHandle<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandle<typeof function commentsQuery(input: {
slug: string;
}): InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>
commentsQuery> }) {
// ...
}
// Fed by `ensure` or `fetch`: `useQuery` returns the data.
function function Post(props: {
handle: QueryHandleWithData<typeof postQuery>;
}): void
Post(props: {
handle: QueryHandleWithData<typeof postQuery>;
}
props: { handle: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
handle: interface QueryHandleWithData<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandleWithData<typeof function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery> }) {
// ...
}A handle's type names its factory, and a factory's type includes its key's shape (readonly ["post", string]). So useQuery(draftQuery, handle) is a type error when handle came from postQuery, even when both take the same input and return the same data. Factories whose keys have the same shape read the same entries, so their handles mix. Annotating a factory's return type as QueryOptions<{ data: Post }> widens its key to any QueryKey, and its handles mix with any factory's of the same input and data.
QueryHandleWithData extends QueryHandle, so TypeScript lets a handle with data into a QueryHandle prop. useQuery picks by the handle it gets at runtime, though: there it returns the data while the prop's type says promise, and use() on it throws. If you switch a query from prefetch to ensure, switch the props that receive its handle to QueryHandleWithData.
The cache is the source of truth
data is what the cache held when the verb resolved. useQuery reads the entry's current data instead: after a refetch, an invalidation or a setData, every reader updates, which a value frozen into loader data couldn't do.
If the entry has lost its data since — it was garbage-collected while the router kept the loader data, or something called reset — useQuery suspends until it's refetched instead of returning undefined.
Where handles come from
qc.prefetch(factory, input)returns aQueryHandlesynchronously.await qc.ensure(factory, input)andawait qc.fetch(factory, input)return aQueryHandleWithData.useWaterfall(factory, input)starts a query during render and returns aQueryHandle. It's an escape hatch.
You never build handles by hand: the factory's key and input must agree, and the verbs guarantee it.