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

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. It's a query's key and its factory's input, and nothing else:

type 
type Handle = QueryHandle<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>
Handle
= interface QueryHandle<TFactory extends QueryFactory = (input: unknown) => { readonly queryFn: (context: never) => unknown; readonly queryKey: QueryKey; }>

A small serializable reference to a query: its key and its factory's input. It carries no data; readers get that from the cache. Handles of different factories have different types.

QueryHandle
<typeof
function postQuery(input: {
    slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
postQuery
>;
// { queryKey: readonly [Group, "bySlug", string]; input: { slug: string } }
const handle: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>
handle
.queryKey;
QueryHandle<(input: { slug: string; }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>.queryKey: readonly ["posts", "bySlug", string]
const handle: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>
handle
.input;
QueryHandle<(input: { slug: string; }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>.input: {
    slug: string;
}

A handle carries no data, so it serializes for free: the TanStack Router serializer, React Router's loader data and React's RSC payload all send it as is. The data reaches the browser's cache through hydration, once, however many handles point at it.

The data the loader waited for

Every entry gives the same QueryHandle, whether the loader waited for it or not. The data itself is in the second element of what load resolves to, for the code that called it. A loader can branch on it or build another query's input from it, and a server component can render it. It's the cache entry's own object (through select), not a copy.

Reading a handle

useQuery takes the factory as well as the handle because the handle is plain data, { queryKey, input }, so it can cross from a loader or a server component to the browser, and a function can't: the handle can't carry the queryFn, so you pass the factory alongside it.

useQuery returns a thenable for every handle, and use() reads it. What the loader did decides whether that read suspends: an swr or revalidate handle's thenable is already fulfilled, so use() returns the data at once, and a prefetch handle's suspends until the data lands:

const 
const loaderData: {
    readonly post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>;
    readonly comments: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>;
}
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 [
const postThenable: QueryValue<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>
postThenable
] =
useQuery<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>(factory: (input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, handle: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>, options?: UseQueryOptions): readonly [...]

Read a query by its handle.

useQuery
(
function postQuery(input: {
    slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
postQuery
,
const loaderData: {
    readonly post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>;
    readonly comments: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>;
}
loaderData
.
post: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>
post
); // swr
const post = use<Post>(usable: React.Usable<Post>): Postuse(
const postThenable: QueryValue<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>
postThenable
); // fulfilled: returns at once
const post: Post
const [
const commentsThenable: QueryValue<(input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>
commentsThenable
] =
useInfiniteQuery<(input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>(factory: (input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, handle: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>, options?: UseQueryOptions): readonly [...]

Read a paged query (infiniteQueryOptions) by its handle. The data is { pages, pageParams }, and its state loads more pages.

useInfiniteQuery
(
function commentsQuery(input: {
    slug: string;
}): InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>
commentsQuery
,
const loaderData: {
    readonly post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>;
    readonly comments: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>;
}
loaderData
.
comments: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>
comments
); // prefetch
const const comments: InfiniteData<CommentPage, number>comments = use<InfiniteData<CommentPage, number>>(usable: React.Usable<InfiniteData<CommentPage, number>>): InfiniteData<CommentPage, number>use(
const commentsThenable: QueryValue<(input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>
commentsThenable
); // suspends until the comments land

So a prop takes a QueryHandle whichever entry fed it, and switching a loader from prefetch to swr changes nothing in the components.

A handle's type names its factory, and a factory's type includes its key's shape (readonly ["posts", "bySlug", 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.

The cache is the source of truth

load's data is what the cache held when it 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.

A loader can't promise the data is still there when a component renders. If the entry has lost it since (it was garbage-collected while the router kept the loader data, or something called resetOne or removeOne), use() on the thenable suspends until the query is refetched instead of returning undefined. That's why useQuery returns a thenable even for a handle the loader waited for.

Where handles come from

  • load(qc, entries) resolves to a QueryHandle per entry.
  • waterfallHandle(factory, input) returns a QueryHandle that starts nothing: the component that reads it fetches while rendering. It's an escape hatch. waterfallHandle(factory, handle, (input) => next) derives one from another handle's input, such as a loader's, and returns that handle as is while next maps to the same query (URL state).

You never build handles by hand: the factory's key and input must agree, and load and waterfallHandle guarantee it.