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:

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
>;
type Handle = QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
// { 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
>;
type WithData = QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
// { 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
); // ensured
const post: Post
const [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
); // prefetched
const commentsPromise: Thenable<InfiniteData<{
    page: CommentPage;
    pageParam: number;
}>>

Type 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 a QueryHandle synchronously.
  • await qc.ensure(factory, input) and await qc.fetch(factory, input) return a QueryHandleWithData.
  • useWaterfall(factory, input) starts a query during render and returns a QueryHandle. It's an escape hatch.

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