Skip to content

raf-query is early. The API can still change between minor releases, so use it at your own risk.

raf-query
Early · 0.x on npm

Render-as-you-fetch data fetching for React

Queries start in route loaders and React Server Components, travel to components as small serializable handles, and are read with React’s use() under Suspense. The cache works like TanStack Query’s.

$ pnpm add @raf-query/react effect

src/routes/blog.$slug.tsx
export const 
const Route: Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/blog/$slug", "/blog/$slug", "/blog/$slug", "/blog/$slug", undefined, ResolveParams<"/blog/$slug">, AnyContext, AnyContext, AnyContext, {}, (args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, ... 8 more ..., undefined>, ... 7 more ..., undefined>) => {
    ...;
}, ... 4 more ..., undefined>
Route
=
createFileRoute<"/blog/$slug", RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/blog/$slug", "/blog/$slug", "/blog/$slug">(path?: "/blog/$slug" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<...> & ... 1 more ... & UpdatableRouteOptions<...>) | undefined) => Route<...>
createFileRoute
("/blog/$slug")({
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, {}, ... 5 more ..., undefined>, ... 12 more ..., undefined>.loader?: Constrain<(args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, ... 8 more ..., undefined>, ... 7 more ..., undefined>) => {
    ...;
}, RouteLoaderFn<...> | RouteLoaderObject<...>> | undefined
loader
: (
args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/blog/$slug", ResolveParams<"/blog/$slug">, {}, AnyContext, AnyContext, AnyContext, unknown, undefined>
args
) => {
const const qc: QueryClient<never>qc =
args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/blog/$slug", ResolveParams<"/blog/$slug">, {}, AnyContext, AnyContext, AnyContext, unknown, undefined>
args
.
LoaderFnContext<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 7 more ..., undefined>.context: {
    queryClient: QueryClient;
}
context
.queryClient: QueryClient<never>queryClient;
const { const slug: stringslug } =
args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/blog/$slug", ResolveParams<"/blog/$slug">, {}, AnyContext, AnyContext, AnyContext, unknown, undefined>
args
.
LoaderFnContext<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, {}, ... 5 more ..., undefined>, ... 7 more ..., undefined>.params: {
    slug: string;
}
params
;
// Never waits: both 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 });
const
const post: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
= 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 });
return {
comments: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
comments
,
post: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
};
}, UpdatableRouteOptionsExtensions.component?: RouteComponent | undefinedcomponent: function PostPage(): React.JSX.ElementPostPage, }); function function PostPage(): React.JSX.ElementPostPage() { const
const data: {
    comments: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<Flat<{
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
    }>>>;
    post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>;
}
data
=
const Route: Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/blog/$slug", "/blog/$slug", "/blog/$slug", "/blog/$slug", undefined, ResolveParams<"/blog/$slug">, AnyContext, AnyContext, AnyContext, {}, (args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, ... 8 more ..., undefined>, ... 7 more ..., undefined>) => {
    ...;
}, ... 4 more ..., undefined>
Route
.
RouteExtensions<"/blog/$slug", "/blog/$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<...>>, "/blog/$slug", true, unknown, boolean> & OptionalStructuralSharing<...>) | undefined) => {
    ...;
}
useLoaderData
();
return ( <React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article> <const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense React.SuspenseProps.fallback?: React.ReactNodefallback={<function PostSkeleton(): React.ReactNodePostSkeleton />}> <
function Post(props: {
    handle: QueryHandle<typeof postQuery>;
}): React.JSX.Element
Post
handle: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
handle
={
const data: {
    comments: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<Flat<{
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
    }>>>;
    post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>;
}
data
.
post: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
} />
</const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense> <const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense React.SuspenseProps.fallback?: React.ReactNodefallback={<function CommentsSkeleton(): React.JSX.ElementCommentsSkeleton />}> <
function PostComments(props: {
    handle: QueryHandle<typeof commentsQuery>;
}): React.JSX.Element[]
PostComments
handle: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
handle
={
const data: {
    comments: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<Flat<{
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
    }>>>;
    post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>;
}
data
.
comments: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
comments
} />
</const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense> </React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article> ); } function
function Post(props: {
    handle: QueryHandle<typeof postQuery>;
}): React.JSX.Element
Post
(
props: {
    handle: QueryHandle<typeof postQuery>;
}
props
: {
handle: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
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
> }) {
const [postPromise] =
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: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>, options?: (Options & {
    readonly throwOnError?: never;
} & {
    readonly throwOnError?: undefined;
}) | undefined): readonly [promise: Thenable<Post>, controls: QueryControls<...>] (+1 overload)
useQuery
(
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
,
props: {
    handle: QueryHandle<typeof postQuery>;
}
props
.
handle: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
handle
);
const postPromise: Thenable<Post>
const const post: Postpost = use<Post>(usable: React.Usable<Post>): Postuse(const postPromise: Thenable<Post>postPromise); return <React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>{const post: Postpost.Post.title: stringtitle}</React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>; }
src/app/(blog)/posts/[slug]/page.tsx
export default async function 
function Page(props: {
    params: Promise<{
        slug: string;
    }>;
}): Promise<JSX.Element>
Page
(
props: {
    params: Promise<{
        slug: string;
    }>;
}
props
: {
params: Promise<{
    slug: string;
}>
params
: interface Promise<T>Promise<{ slug: stringslug: string }>;
}) { const { const slug: stringslug } = await
props: {
    params: Promise<{
        slug: string;
    }>;
}
props
.
params: Promise<{
    slug: string;
}>
params
;
const const qc: QueryClient<never>qc = function getQueryClient(): QueryClient<never>getQueryClient(); // Never waits: both load while the page streams. 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 });
const
const post: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
= 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 });
return ( <function HydrationBoundary(props: HydrationBoundaryProps): JSX.ElementHydrationBoundary client: QueryClient<never>client={const qc: QueryClient<never>qc}> <JSX.IntrinsicElements.article: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>article> <const Suspense: ExoticComponent<SuspenseProps>Suspense SuspenseProps.fallback?: ReactNodefallback={<function PostSkeleton(): JSX.ElementPostSkeleton />}> <
function Post(props: {
    handle: QueryHandle<typeof postQuery>;
}): JSX.Element
Post
handle: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
handle
={
const post: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
} />
</const Suspense: ExoticComponent<SuspenseProps>Suspense> <const Suspense: ExoticComponent<SuspenseProps>Suspense SuspenseProps.fallback?: ReactNodefallback={<function CommentsSkeleton(): JSX.ElementCommentsSkeleton />}> <
function Comments(props: {
    handle: QueryHandle<typeof commentsQuery>;
}): JSX.Element[]
Comments
handle: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
handle
={
const comments: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
comments
} />
</const Suspense: ExoticComponent<SuspenseProps>Suspense> </JSX.IntrinsicElements.article: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>article> </function HydrationBoundary(props: HydrationBoundaryProps): JSX.ElementHydrationBoundary> ); }
src/app/(blog)/components/post.tsx
"use client";
export function 
function Post(props: {
    handle: QueryHandle<typeof postQuery>;
}): JSX.Element
Post
(
props: {
    handle: QueryHandle<typeof postQuery>;
}
props
: {
handle: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
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
> }) {
const [postPromise] =
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: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>, options?: (Options & {
    readonly throwOnError?: never;
} & {
    readonly throwOnError?: undefined;
}) | undefined): readonly [promise: Thenable<Post>, controls: QueryControls<...>] (+1 overload)
useQuery
(
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
,
props: {
    handle: QueryHandle<typeof postQuery>;
}
props
.
handle: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
handle
);
const postPromise: Thenable<Post>
const const post: Postpost = use<Post>(usable: Usable<Post>): Postuse(const postPromise: Thenable<Post>postPromise); return <JSX.IntrinsicElements.h1: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>{const post: Postpost.Post.title: stringtitle}</JSX.IntrinsicElements.h1: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>; }
src/app/(blog)/components/comments.tsx
app/routes/blog.$slug.tsx
export function 
function loader(args: Route.LoaderArgs): {
    comments: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<Flat<{
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
    }>>>;
    dehydratedState: DehydratedState;
    post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>;
}
loader
(
args: CreateServerLoaderArgs<Info & {
    module: typeof import("./blog.$slug");
    matches: Matches;
}>
args
: Route.
type Route.LoaderArgs = {
    request: Request;
    url: URL;
    params: {
        slug: string;
    };
    pattern: string;
    context: Readonly<RouterContextProvider>;
}
LoaderArgs
) {
const const qc: QueryClient<never>qc =
args: CreateServerLoaderArgs<Info & {
    module: typeof import("./blog.$slug");
    matches: Matches;
}>
args
.context: Readonly<RouterContextProvider>context.get<QueryClient<never>>(context: RouterContext<QueryClient<never>>): QueryClient<never>get(const queryClientContext: RouterContext<QueryClient<never>>queryClientContext);
const { const slug: stringslug } =
args: CreateServerLoaderArgs<Info & {
    module: typeof import("./blog.$slug");
    matches: Matches;
}>
args
.
params: {
    slug: string;
}
params
;
// Never waits: both 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 });
const
const post: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
= 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 });
return {
comments: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
comments
, dehydratedState: DehydratedStatedehydratedState: function dehydrate(client: QueryClient, options?: DehydrateOptions): DehydratedStatedehydrate(const qc: QueryClient<never>qc),
post: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
};
} export default function function PostPage(props: Route.ComponentProps): React.JSX.ElementPostPage(
props: CreateComponentProps<Info & {
    module: typeof import("./blog.$slug");
    matches: Matches;
}, false>
props
: Route.
type Route.ComponentProps = {
    params: {
        slug: string;
    };
    matches: [{
        id: "root";
        params: Record<string, string | undefined>;
        pathname: string;
        loaderData: undefined;
        handle: unknown;
    }, {
        id: "routes/blog.$slug";
        params: Record<string, string | undefined>;
        pathname: string;
        loaderData: {
            comments: {
                readonly input: {
                    slug: string;
                };
                readonly key: readonly undefined[];
                readonly [factoryType]?: undefined;
            };
            dehydratedState: {
                readonly queries: readonly {
                    readonly data?: undefined;
                    readonly effect?: boolean | undefined;
                    readonly key: readonly undefined[];
                    readonly meta?: {
                        [x: string]: undefined;
                    } | undefined;
                    readonly promise?: Promise<{
                        readonly data?: undefined;
                        readonly updatedAt: number;
                    }> | undefined;
                    readonly updatedAt: number;
                }[];
            };
            post: {
                ...;
            };
        };
        handle: unknown;
    }, ...({
        ...;
    } | undefined)[]];
} & {
    ...;
}
ComponentProps
) {
const {
const comments: {
    readonly input: {
        slug: string;
    };
    readonly key: readonly undefined[];
    readonly [factoryType]?: undefined;
}
comments
,
const dehydratedState: {
    readonly queries: readonly {
        readonly data?: undefined;
        readonly effect?: boolean | undefined;
        readonly key: readonly undefined[];
        readonly meta?: {
            [x: string]: undefined;
        } | undefined;
        readonly promise?: Promise<{
            readonly data?: undefined;
            readonly updatedAt: number;
        }> | undefined;
        readonly updatedAt: number;
    }[];
}
dehydratedState
,
const post: {
    readonly input: {
        slug: string;
    };
    readonly key: readonly undefined[];
    readonly [factoryType]?: undefined;
}
post
} =
props: CreateComponentProps<Info & {
    module: typeof import("./blog.$slug");
    matches: Matches;
}, false>
props
.
loaderData: {
    comments: {
        readonly input: {
            slug: string;
        };
        readonly key: readonly undefined[];
        readonly [factoryType]?: undefined;
    };
    dehydratedState: {
        readonly queries: readonly {
            readonly data?: undefined;
            readonly effect?: boolean | undefined;
            readonly key: readonly undefined[];
            readonly meta?: {
                [x: string]: undefined;
            } | undefined;
            readonly promise?: Promise<{
                readonly data?: undefined;
                readonly updatedAt: number;
            }> | undefined;
            readonly updatedAt: number;
        }[];
    };
    post: {
        ...;
    };
}
loaderData
;
return ( <function HydrationBoundary(props: HydrationBoundaryProps): React.JSX.ElementHydrationBoundary state: DehydratedStatestate={
const dehydratedState: {
    readonly queries: readonly {
        readonly data?: undefined;
        readonly effect?: boolean | undefined;
        readonly key: readonly undefined[];
        readonly meta?: {
            [x: string]: undefined;
        } | undefined;
        readonly promise?: Promise<{
            readonly data?: undefined;
            readonly updatedAt: number;
        }> | undefined;
        readonly updatedAt: number;
    }[];
}
dehydratedState
}>
<React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article> <const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense React.SuspenseProps.fallback?: React.ReactNodefallback={<function PostSkeleton(): React.ReactNodePostSkeleton />}> <
function Post(props: {
    handle: QueryHandle<typeof postQuery>;
}): React.JSX.Element
Post
handle: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
handle
={
const post: {
    readonly input: {
        slug: string;
    };
    readonly key: readonly undefined[];
    readonly [factoryType]?: undefined;
}
post
} />
</const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense> <const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense React.SuspenseProps.fallback?: React.ReactNodefallback={<function CommentsSkeleton(): React.JSX.ElementCommentsSkeleton />}> <
function PostComments(props: {
    handle: QueryHandle<typeof commentsQuery>;
}): React.JSX.Element[]
PostComments
handle: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
handle
={
const comments: {
    readonly input: {
        slug: string;
    };
    readonly key: readonly undefined[];
    readonly [factoryType]?: undefined;
}
comments
} />
</const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense> </React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article> </function HydrationBoundary(props: HydrationBoundaryProps): React.JSX.ElementHydrationBoundary> ); } function
function Post(props: {
    handle: QueryHandle<typeof postQuery>;
}): React.JSX.Element
Post
(
props: {
    handle: QueryHandle<typeof postQuery>;
}
props
: {
handle: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
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
> }) {
const [postPromise] =
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: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>, options?: (Options & {
    readonly throwOnError?: never;
} & {
    readonly throwOnError?: undefined;
}) | undefined): readonly [promise: Thenable<Post>, controls: QueryControls<...>] (+1 overload)
useQuery
(
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
,
props: {
    handle: QueryHandle<typeof postQuery>;
}
props
.
handle: QueryHandle<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
handle
);
const postPromise: Thenable<Post>
const const post: Postpost = use<Post>(usable: React.Usable<Post>): Postuse(const postPromise: Thenable<Post>postPromise); return <React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>{const post: Postpost.Post.title: stringtitle}</React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>; }

How a route loads its data

A page says what it needs before it renders. Each query is defined once, the route starts every request when the navigation begins, and components read the results as they arrive.

  1. Define query factories

    A query factory is a function of the query’s input that returns queryOptions, as in TanStack Query: a key, a queryFn, and options like staleTime. Comments load a page at a time, so commentsQuery uses infiniteQueryOptions.

    getPost is a server function: during SSR it runs in the process, and refetches in the browser call it over HTTP.

    Every API takes the factory and its input instead of a key: starting a query, reading it, invalidating it. The key and the request are written once, and the data’s type flows from queryFn to the component that reads it.

    The input has to be serializable: it’s what a handle carries from the server to the browser.

    Query factories
    src/queries.ts
    import { 
    function infiniteQueryOptions<TPage, TPageParam, TError = unknown, TServices = never, const TKey extends QueryKey = QueryKey, TThrow extends boolean | undefined = undefined>(options: Omit<InfiniteQueryOptions<{
        error: TError;
        key: TKey;
        page: TPage;
        pageParam: TPageParam;
        services: TServices;
    }>, "kind" | "throwOnError"> & ThrowOnErrorField<TThrow>): InfiniteQueryOptions<Flat<{
        key: TKey;
        page: TPage;
        pageParam: TPageParam;
    } & ErrorPart<TError> & ServicesPart<TServices> & ThrowPart<...>>>
    infiniteQueryOptions
    ,
    function queryOptions<TData, TError = unknown, TServices = never, const TKey extends QueryKey = QueryKey, TThrow extends boolean | undefined = undefined>(options: Omit<QueryOptions<{
        data: TData;
        error: TError;
        key: TKey;
        services: TServices;
    }>, "kind" | "throwOnError"> & ThrowOnErrorField<TThrow>): QueryOptions<Flat<{
        data: TData;
        key: TKey;
    } & ErrorPart<TError> & ServicesPart<TServices> & ThrowPart<TThrow>>>
    queryOptions
    } from "@raf-query/react";
    import { type CommentPage,
    function listComments(input: {
        cursor: number;
        slug: string;
    }): Promise<CommentPage>
    listComments
    } from "./api.ts";
    import {
    const getPost: RequiredFetcher<undefined, ZodObject<{
        slug: ZodString;
    }, $strip>, Promise<Post>>
    getPost
    } from "./posts.functions.ts";
    export function
    function postQuery(input: {
        slug: string;
    }): QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>
    postQuery
    (
    input: {
        slug: string;
    }
    input
    : { slug: stringslug: string }) {
    return
    queryOptions<Post, unknown, never, readonly ["post", string], undefined>(options: Omit<QueryOptions<{
        data: Post;
        error: unknown;
        key: readonly ["post", string];
        services: never;
    }>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>
    queryOptions
    ({
    queryKey: readonly ["post", string]queryKey: ["post",
    input: {
        slug: string;
    }
    input
    .slug: stringslug],
    function queryFn(context: QueryFunctionContext): QueryFnResult<{
        data: Post;
        error: unknown;
        key: readonly ["post", string];
        services: never;
    }>
    queryFn
    : () =>
    function getPost(opts: RequiredFetcherDataOptions<undefined, ZodObject<{
        slug: ZodString;
    }, $strip>>): Promise<Post>
    getPost
    ({
    RequiredFetcherDataOptions<undefined, ZodObject<{ slug: ZodString; }, $strip>>.data: {
        slug: string;
    }
    data
    :
    input: {
        slug: string;
    }
    input
    }),
    }); } export function
    function commentsQuery(input: {
        slug: string;
    }): InfiniteQueryOptions<Flat<{
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
    }>>
    commentsQuery
    (
    input: {
        slug: string;
    }
    input
    : { slug: stringslug: string }) {
    return
    infiniteQueryOptions<CommentPage, number, unknown, never, readonly ["comments", string], undefined>(options: Omit<InfiniteQueryOptions<{
        error: unknown;
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
        services: never;
    }>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): InfiniteQueryOptions<Flat<{
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
    }>>
    infiniteQueryOptions
    ({
    queryKey: readonly ["comments", string]queryKey: ["comments",
    input: {
        slug: string;
    }
    input
    .slug: stringslug],
    function queryFn(context: PageFunctionContext<number>): QueryFnResult<{
        data: CommentPage;
        error: unknown;
        services: never;
    }>
    queryFn
    : (context: PageFunctionContext<number>context) =>
    function listComments(input: {
        cursor: number;
        slug: string;
    }): Promise<CommentPage>
    listComments
    ({ cursor: numbercursor: context: PageFunctionContext<number>context.PageFunctionContext<number>.pageParam: numberpageParam, slug: stringslug:
    input: {
        slug: string;
    }
    input
    .slug: stringslug }),
    initialPageParam: numberinitialPageParam: 0, function getNextPageParam(lastPage: CommentPage, pages: readonly CommentPage[], lastPageParam: number, pageParams: readonly number[]): number | null | undefinedgetNextPageParam: (lastPage: CommentPagelastPage: CommentPage) => lastPage: CommentPagelastPage.CommentPage.nextCursor: number | nullnextCursor, }); }
  2. Start them in the route loader

    The router runs the loader as soon as a navigation starts, and with preloading on, when a link is hovered. Every query the page needs starts there, at the same time, before anything renders.

    prefetch never holds the navigation: the page renders while the post and comments load. Each call returns a handle — the query’s key and input, without the data. During SSR, the cache streams to the browser next to those handles, so nothing is fetched twice.

    Use ensure when the route can’t lay out without the data. Prefer prefetch when a component will read it under Suspense — the usual shape, and what the example app does for streamed parts of the page.

    Starting queries
    src/routes/blog.$slug.tsx
    export const 
    const Route: Route<Register, RootRoute<Register, undefined, {
        queryClient: QueryClient;
    }, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/blog/$slug", "/blog/$slug", "/blog/$slug", "/blog/$slug", undefined, ResolveParams<"/blog/$slug">, AnyContext, AnyContext, AnyContext, {}, (args: LoaderFnContext<Register, RootRoute<Register, undefined, {
        queryClient: QueryClient;
    }, ... 8 more ..., undefined>, ... 7 more ..., undefined>) => {
        ...;
    }, ... 4 more ..., undefined>
    Route
    =
    createFileRoute<"/blog/$slug", RootRoute<Register, undefined, {
        queryClient: QueryClient;
    }, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/blog/$slug", "/blog/$slug", "/blog/$slug">(path?: "/blog/$slug" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<...> & ... 1 more ... & UpdatableRouteOptions<...>) | undefined) => Route<...>
    createFileRoute
    ("/blog/$slug")({
    FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, {}, ... 5 more ..., undefined>, ... 12 more ..., undefined>.loader?: Constrain<(args: LoaderFnContext<Register, RootRoute<Register, undefined, {
        queryClient: QueryClient;
    }, ... 8 more ..., undefined>, ... 7 more ..., undefined>) => {
        ...;
    }, RouteLoaderFn<...> | RouteLoaderObject<...>> | undefined
    loader
    : (
    args: LoaderFnContext<Register, RootRoute<Register, undefined, {
        queryClient: QueryClient;
    }, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/blog/$slug", ResolveParams<"/blog/$slug">, {}, AnyContext, AnyContext, AnyContext, unknown, undefined>
    args
    ) => {
    const const qc: QueryClient<never>qc =
    args: LoaderFnContext<Register, RootRoute<Register, undefined, {
        queryClient: QueryClient;
    }, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/blog/$slug", ResolveParams<"/blog/$slug">, {}, AnyContext, AnyContext, AnyContext, unknown, undefined>
    args
    .
    LoaderFnContext<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 7 more ..., undefined>.context: {
        queryClient: QueryClient;
    }
    context
    .queryClient: QueryClient<never>queryClient;
    const { const slug: stringslug } =
    args: LoaderFnContext<Register, RootRoute<Register, undefined, {
        queryClient: QueryClient;
    }, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/blog/$slug", ResolveParams<"/blog/$slug">, {}, AnyContext, AnyContext, AnyContext, unknown, undefined>
    args
    .
    LoaderFnContext<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, {}, ... 5 more ..., undefined>, ... 7 more ..., undefined>.params: {
        slug: string;
    }
    params
    ;
    // Never waits: both 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 });
    const
    const post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>
    post
    = 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 });
    return {
    comments: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<Flat<{
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
    }>>>
    comments
    ,
    post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>
    post
    };
    }, UpdatableRouteOptionsExtensions.component?: RouteComponent | undefinedcomponent: function PostPage(): React.JSX.ElementPostPage, });
  3. Read them in components

    useQuery takes the factory and a handle and returns a promise. use() suspends that subtree while its skeleton shows, so the rest of the page can stream in around it.

    From then on the cache keeps both up to date: it refetches stale data when the window regains focus and after a write invalidates it, and each component re-renders with the new data.

    Reading queries
    src/routes/blog.$slug.tsx
    function function PostPage(): React.JSX.ElementPostPage() {
    	const 
    const data: {
        comments: QueryHandle<(input: {
            slug: string;
        }) => InfiniteQueryOptions<Flat<{
            key: readonly ["comments", string];
            page: CommentPage;
            pageParam: number;
        }>>>;
        post: QueryHandle<(input: {
            slug: string;
        }) => QueryOptions<Flat<{
            data: Post;
            key: readonly ["post", string];
        }>>>;
    }
    data
    =
    const Route: Route<Register, RootRoute<Register, undefined, {
        queryClient: QueryClient;
    }, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/blog/$slug", "/blog/$slug", "/blog/$slug", "/blog/$slug", undefined, ResolveParams<"/blog/$slug">, AnyContext, AnyContext, AnyContext, {}, (args: LoaderFnContext<Register, RootRoute<Register, undefined, {
        queryClient: QueryClient;
    }, ... 8 more ..., undefined>, ... 7 more ..., undefined>) => {
        ...;
    }, ... 4 more ..., undefined>
    Route
    .
    RouteExtensions<"/blog/$slug", "/blog/$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<...>>, "/blog/$slug", true, unknown, boolean> & OptionalStructuralSharing<...>) | undefined) => {
        ...;
    }
    useLoaderData
    ();
    return ( <React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article> <const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense React.SuspenseProps.fallback?: React.ReactNodefallback={<function PostSkeleton(): React.ReactNodePostSkeleton />}> <
    function Post(props: {
        handle: QueryHandle<typeof postQuery>;
    }): React.JSX.Element
    Post
    handle: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>
    handle
    ={
    const data: {
        comments: QueryHandle<(input: {
            slug: string;
        }) => InfiniteQueryOptions<Flat<{
            key: readonly ["comments", string];
            page: CommentPage;
            pageParam: number;
        }>>>;
        post: QueryHandle<(input: {
            slug: string;
        }) => QueryOptions<Flat<{
            data: Post;
            key: readonly ["post", string];
        }>>>;
    }
    data
    .
    post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>
    post
    } />
    </const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense> <const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense React.SuspenseProps.fallback?: React.ReactNodefallback={<function CommentsSkeleton(): React.JSX.ElementCommentsSkeleton />}> <
    function PostComments(props: {
        handle: QueryHandle<typeof commentsQuery>;
    }): React.JSX.Element[]
    PostComments
    handle: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<Flat<{
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
    }>>>
    handle
    ={
    const data: {
        comments: QueryHandle<(input: {
            slug: string;
        }) => InfiniteQueryOptions<Flat<{
            key: readonly ["comments", string];
            page: CommentPage;
            pageParam: number;
        }>>>;
        post: QueryHandle<(input: {
            slug: string;
        }) => QueryOptions<Flat<{
            data: Post;
            key: readonly ["post", string];
        }>>>;
    }
    data
    .
    comments: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<Flat<{
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
    }>>>
    comments
    } />
    </const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense> </React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article> ); } function
    function Post(props: {
        handle: QueryHandle<typeof postQuery>;
    }): React.JSX.Element
    Post
    (
    props: {
        handle: QueryHandle<typeof postQuery>;
    }
    props
    : {
    handle: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>
    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
    > }) {
    const [postPromise] =
    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: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>, options?: (Options & {
        readonly throwOnError?: never;
    } & {
        readonly throwOnError?: undefined;
    }) | undefined): readonly [promise: Thenable<Post>, controls: QueryControls<...>] (+1 overload)
    useQuery
    (
    function postQuery(input: {
        slug: string;
    }): QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>
    postQuery
    ,
    props: {
        handle: QueryHandle<typeof postQuery>;
    }
    props
    .
    handle: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>
    handle
    );
    const postPromise: Thenable<Post>
    const const post: Postpost = use<Post>(usable: React.Usable<Post>): Postuse(const postPromise: Thenable<Post>postPromise); return <React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>{const post: Postpost.Post.title: stringtitle}</React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>; }

A page says what it needs before it renders. Each query is defined once, the server component starts every request when the route renders, and client components read the results as they arrive.

  1. Define query factories

    A query factory is a function of the query’s input that returns queryOptions, as in TanStack Query: a key, a queryFn, and options like staleTime. Comments load a page at a time, so commentsQuery uses infiniteQueryOptions.

    Every API takes the factory and its input instead of a key: starting a query, reading it, invalidating it. The key and the request are written once, and the data’s type flows from queryFn to the component that reads it.

    The input has to be serializable: it’s what a handle carries from the server to the browser.

    Query factories
    src/queries.ts
    import { 
    function infiniteQueryOptions<TPage, TPageParam, TError = unknown, TServices = never, const TKey extends QueryKey = QueryKey, TThrow extends boolean | undefined = undefined>(options: Omit<InfiniteQueryOptions<{
        error: TError;
        key: TKey;
        page: TPage;
        pageParam: TPageParam;
        services: TServices;
    }>, "kind" | "throwOnError"> & ThrowOnErrorField<TThrow>): InfiniteQueryOptions<Flat<{
        key: TKey;
        page: TPage;
        pageParam: TPageParam;
    } & ErrorPart<TError> & ServicesPart<TServices> & ThrowPart<...>>>
    infiniteQueryOptions
    ,
    function queryOptions<TData, TError = unknown, TServices = never, const TKey extends QueryKey = QueryKey, TThrow extends boolean | undefined = undefined>(options: Omit<QueryOptions<{
        data: TData;
        error: TError;
        key: TKey;
        services: TServices;
    }>, "kind" | "throwOnError"> & ThrowOnErrorField<TThrow>): QueryOptions<Flat<{
        data: TData;
        key: TKey;
    } & ErrorPart<TError> & ServicesPart<TServices> & ThrowPart<TThrow>>>
    queryOptions
    } from "@raf-query/react";
    import { function getPost(slug: string): Promise<Post>getPost,
    function listComments(input: {
        cursor: number;
        slug: string;
    }): Promise<CommentPage>
    listComments
    } from "./api.ts";
    export function
    function postQuery(input: {
        slug: string;
    }): QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>
    postQuery
    (
    input: {
        slug: string;
    }
    input
    : { slug: stringslug: string }) {
    return
    queryOptions<Post, unknown, never, readonly ["post", string], undefined>(options: Omit<QueryOptions<{
        data: Post;
        error: unknown;
        key: readonly ["post", string];
        services: never;
    }>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>
    queryOptions
    ({
    queryKey: readonly ["post", string]queryKey: ["post",
    input: {
        slug: string;
    }
    input
    .slug: stringslug],
    function queryFn(context: QueryFunctionContext): QueryFnResult<{
        data: Post;
        error: unknown;
        key: readonly ["post", string];
        services: never;
    }>
    queryFn
    : () => function getPost(slug: string): Promise<Post>getPost(
    input: {
        slug: string;
    }
    input
    .slug: stringslug),
    }); } export function
    function commentsQuery(input: {
        slug: string;
    }): InfiniteQueryOptions<Flat<{
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
    }>>
    commentsQuery
    (
    input: {
        slug: string;
    }
    input
    : { slug: stringslug: string }) {
    return
    infiniteQueryOptions<CommentPage, number, unknown, never, readonly ["comments", string], undefined>(options: Omit<InfiniteQueryOptions<{
        error: unknown;
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
        services: never;
    }>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): InfiniteQueryOptions<Flat<{
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
    }>>
    infiniteQueryOptions
    ({
    queryKey: readonly ["comments", string]queryKey: ["comments",
    input: {
        slug: string;
    }
    input
    .slug: stringslug],
    function queryFn(context: PageFunctionContext<number>): QueryFnResult<{
        data: CommentPage;
        error: unknown;
        services: never;
    }>
    queryFn
    : (context: PageFunctionContext<number>context) =>
    function listComments(input: {
        cursor: number;
        slug: string;
    }): Promise<CommentPage>
    listComments
    ({ cursor: numbercursor: context: PageFunctionContext<number>context.PageFunctionContext<number>.pageParam: numberpageParam, slug: stringslug:
    input: {
        slug: string;
    }
    input
    .slug: stringslug }),
    initialPageParam: numberinitialPageParam: 0, function getNextPageParam(lastPage: CommentPage, pages: readonly CommentPage[], lastPageParam: number, pageParams: readonly number[]): number | null | undefinedgetNextPageParam: (lastPage: CommentPagelastPage) => lastPage: CommentPagelastPage.CommentPage.nextCursor: number | nullnextCursor, }); }
  2. Start them in a server component

    A server component runs as soon as the App Router starts rendering the page. Every query the page needs starts there, at the same time, before client components render.

    prefetch never holds the response: the page streams while the post and comments load. Each call returns a handle — the query’s key and input, without the data. HydrationBoundary streams the cache to the browser next to those handles, so nothing is fetched twice.

    Use fetch when the server component itself renders the data. Prefer prefetch when a client component will read it under Suspense — the usual App Router shape, and what the example app does.

    Starting queries
    src/app/(blog)/posts/[slug]/page.tsx
    export default async function 
    function Page(props: {
        params: Promise<{
            slug: string;
        }>;
    }): Promise<JSX.Element>
    Page
    (
    props: {
        params: Promise<{
            slug: string;
        }>;
    }
    props
    : {
    params: Promise<{
        slug: string;
    }>
    params
    : interface Promise<T>Promise<{ slug: stringslug: string }>;
    }) { const { const slug: stringslug } = await
    props: {
        params: Promise<{
            slug: string;
        }>;
    }
    props
    .
    params: Promise<{
        slug: string;
    }>
    params
    ;
    const const qc: QueryClient<never>qc = function getQueryClient(): QueryClient<never>getQueryClient(); // Never waits: both load while the page streams. 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 });
    const
    const post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>
    post
    = 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 });
    return ( <function HydrationBoundary(props: HydrationBoundaryProps): JSX.ElementHydrationBoundary client: QueryClient<never>client={const qc: QueryClient<never>qc}> <JSX.IntrinsicElements.article: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>article> <const Suspense: ExoticComponent<SuspenseProps>Suspense SuspenseProps.fallback?: ReactNodefallback={<function PostSkeleton(): JSX.ElementPostSkeleton />}> <
    function Post(props: {
        handle: QueryHandle<typeof postQuery>;
    }): JSX.Element
    Post
    handle: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>
    handle
    ={
    const post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>
    post
    } />
    </const Suspense: ExoticComponent<SuspenseProps>Suspense> <const Suspense: ExoticComponent<SuspenseProps>Suspense SuspenseProps.fallback?: ReactNodefallback={<function CommentsSkeleton(): JSX.ElementCommentsSkeleton />}> <
    function Comments(props: {
        handle: QueryHandle<typeof commentsQuery>;
    }): JSX.Element[]
    Comments
    handle: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<Flat<{
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
    }>>>
    handle
    ={
    const comments: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<Flat<{
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
    }>>>
    comments
    } />
    </const Suspense: ExoticComponent<SuspenseProps>Suspense> </JSX.IntrinsicElements.article: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>article> </function HydrationBoundary(props: HydrationBoundaryProps): JSX.ElementHydrationBoundary> ); }
  3. Read them in client components

    Client components live in their own files, marked with a use client directive. useQuery takes the factory and a handle and returns a promise. use() suspends that subtree while its skeleton shows, so the rest of the page can stream in around it.

    From then on the cache keeps both up to date: it refetches stale data when the window regains focus and after a write invalidates it, and each component re-renders with the new data.

    Reading queries
    src/app/(blog)/components/post.tsx
    "use client";
    export function 
    function Post(props: {
        handle: QueryHandle<typeof postQuery>;
    }): JSX.Element
    Post
    (
    props: {
        handle: QueryHandle<typeof postQuery>;
    }
    props
    : {
    handle: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>
    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
    > }) {
    const [postPromise] =
    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: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>, options?: (Options & {
        readonly throwOnError?: never;
    } & {
        readonly throwOnError?: undefined;
    }) | undefined): readonly [promise: Thenable<Post>, controls: QueryControls<...>] (+1 overload)
    useQuery
    (
    function postQuery(input: {
        slug: string;
    }): QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>
    postQuery
    ,
    props: {
        handle: QueryHandle<typeof postQuery>;
    }
    props
    .
    handle: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>
    handle
    );
    const postPromise: Thenable<Post>
    const const post: Postpost = use<Post>(usable: Usable<Post>): Postuse(const postPromise: Thenable<Post>postPromise); return <JSX.IntrinsicElements.h1: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>{const post: Postpost.Post.title: stringtitle}</JSX.IntrinsicElements.h1: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>; }
    src/app/(blog)/components/comments.tsx
    "use client";
    export function 
    function Comments(props: {
        handle: QueryHandle<typeof commentsQuery>;
    }): JSX.Element[]
    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
    > }) {
    // May still be loading. If so, use() suspends until it lands. const [
    const pagesPromise: Thenable<InfiniteData<{
        page: CommentPage;
        pageParam: number;
    }>>
    pagesPromise
    ] =
    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
    ,
    props: {
        handle: QueryHandle<typeof commentsQuery>;
    }
    props
    .
    handle: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<Flat<{
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
    }>>>
    handle
    );
    const pages =
    use<InfiniteData<{
        page: CommentPage;
        pageParam: number;
    }>>(usable: Usable<InfiniteData<{
        page: CommentPage;
        pageParam: number;
    }>>): InfiniteData<{
        page: CommentPage;
        pageParam: number;
    }>
    use
    (
    const pagesPromise: Thenable<InfiniteData<{
        page: CommentPage;
        pageParam: number;
    }>>
    pagesPromise
    );
    const pages: InfiniteData<{
        page: CommentPage;
        pageParam: number;
    }>
    return
    const pages: InfiniteData<{
        page: CommentPage;
        pageParam: number;
    }>
    pages
    .InfiniteData<{ page: CommentPage; pageParam: number; }>.pages: readonly CommentPage[]pages
    .ReadonlyArray<CommentPage>.flatMap<PostComment, undefined>(callback: (this: undefined, value: CommentPage, index: number, array: CommentPage[]) => PostComment | readonly PostComment[], thisArg?: undefined): PostComment[]flatMap((page: CommentPagepage) => page: CommentPagepage.CommentPage.items: readonly PostComment[]items) .Array<PostComment>.map<JSX.Element>(callbackfn: (value: PostComment, index: number, array: PostComment[]) => JSX.Element, thisArg?: any): JSX.Element[]map((comment: PostCommentcomment) => <
    function Comment(props: {
        comment: PostComment;
    }): React.ReactNode
    Comment
    Attributes.key?: Key | null | undefinedkey={comment: PostCommentcomment.PostComment.id: stringid} comment: PostCommentcomment={comment: PostCommentcomment} />);
    }

A page says what it needs before it renders. Each query is defined once, the route’s loader starts every request when the navigation begins, and components read the results as they arrive.

  1. Define query factories

    A query factory is a function of the query’s input that returns queryOptions, as in TanStack Query: a key, a queryFn, and options like staleTime. Comments load a page at a time, so commentsQuery uses infiniteQueryOptions.

    Every API takes the factory and its input instead of a key: starting a query, reading it, invalidating it. The key and the request are written once, and the data’s type flows from queryFn to the component that reads it.

    The input has to be serializable: it’s what a handle carries from the server to the browser.

    Query factories
    app/queries.ts
    import { 
    function infiniteQueryOptions<TPage, TPageParam, TError = unknown, TServices = never, const TKey extends QueryKey = QueryKey, TThrow extends boolean | undefined = undefined>(options: Omit<InfiniteQueryOptions<{
        error: TError;
        key: TKey;
        page: TPage;
        pageParam: TPageParam;
        services: TServices;
    }>, "kind" | "throwOnError"> & ThrowOnErrorField<TThrow>): InfiniteQueryOptions<Flat<{
        key: TKey;
        page: TPage;
        pageParam: TPageParam;
    } & ErrorPart<TError> & ServicesPart<TServices> & ThrowPart<...>>>
    infiniteQueryOptions
    ,
    function queryOptions<TData, TError = unknown, TServices = never, const TKey extends QueryKey = QueryKey, TThrow extends boolean | undefined = undefined>(options: Omit<QueryOptions<{
        data: TData;
        error: TError;
        key: TKey;
        services: TServices;
    }>, "kind" | "throwOnError"> & ThrowOnErrorField<TThrow>): QueryOptions<Flat<{
        data: TData;
        key: TKey;
    } & ErrorPart<TError> & ServicesPart<TServices> & ThrowPart<TThrow>>>
    queryOptions
    } from "@raf-query/react";
    import { function getPost(slug: string): Promise<Post>getPost,
    function listComments(input: {
        cursor: number;
        slug: string;
    }): Promise<CommentPage>
    listComments
    } from "./api.ts";
    export function
    function postQuery(input: {
        slug: string;
    }): QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>
    postQuery
    (
    input: {
        slug: string;
    }
    input
    : { slug: stringslug: string }) {
    return
    queryOptions<Post, unknown, never, readonly ["post", string], undefined>(options: Omit<QueryOptions<{
        data: Post;
        error: unknown;
        key: readonly ["post", string];
        services: never;
    }>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>
    queryOptions
    ({
    queryKey: readonly ["post", string]queryKey: ["post",
    input: {
        slug: string;
    }
    input
    .slug: stringslug],
    function queryFn(context: QueryFunctionContext): QueryFnResult<{
        data: Post;
        error: unknown;
        key: readonly ["post", string];
        services: never;
    }>
    queryFn
    : () => function getPost(slug: string): Promise<Post>getPost(
    input: {
        slug: string;
    }
    input
    .slug: stringslug),
    }); } export function
    function commentsQuery(input: {
        slug: string;
    }): InfiniteQueryOptions<Flat<{
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
    }>>
    commentsQuery
    (
    input: {
        slug: string;
    }
    input
    : { slug: stringslug: string }) {
    return
    infiniteQueryOptions<CommentPage, number, unknown, never, readonly ["comments", string], undefined>(options: Omit<InfiniteQueryOptions<{
        error: unknown;
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
        services: never;
    }>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): InfiniteQueryOptions<Flat<{
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
    }>>
    infiniteQueryOptions
    ({
    queryKey: readonly ["comments", string]queryKey: ["comments",
    input: {
        slug: string;
    }
    input
    .slug: stringslug],
    function queryFn(context: PageFunctionContext<number>): QueryFnResult<{
        data: CommentPage;
        error: unknown;
        services: never;
    }>
    queryFn
    : (context: PageFunctionContext<number>context) =>
    function listComments(input: {
        cursor: number;
        slug: string;
    }): Promise<CommentPage>
    listComments
    ({ cursor: numbercursor: context: PageFunctionContext<number>context.PageFunctionContext<number>.pageParam: numberpageParam, slug: stringslug:
    input: {
        slug: string;
    }
    input
    .slug: stringslug }),
    initialPageParam: numberinitialPageParam: 0, function getNextPageParam(lastPage: CommentPage, pages: readonly CommentPage[], lastPageParam: number, pageParams: readonly number[]): number | null | undefinedgetNextPageParam: (lastPage: CommentPagelastPage) => lastPage: CommentPagelastPage.CommentPage.nextCursor: number | nullnextCursor, }); }
  2. Start them in the route loader

    React Router runs the loader on the server as soon as a navigation starts. Every query the page needs starts there, at the same time, before anything renders. A root middleware creates one client per request, and every loader reads it from the context: nested routes share a cache, and requests never share data.

    prefetch never holds the navigation: the page renders while the post and comments load. Each call returns a handle — the query’s key and input, without the data. The loader returns the handles with dehydrate(qc), and React Router streams the queries still loading to the browser, so nothing is fetched twice.

    Use fetch when the route can’t lay out without the data. Prefer prefetch when a component will read it under Suspense — the usual shape, and what the example app does for streamed parts of the page.

    React Router
    app/routes/blog.$slug.tsx
    export function 
    function loader(args: Route.LoaderArgs): {
        comments: QueryHandle<(input: {
            slug: string;
        }) => InfiniteQueryOptions<Flat<{
            key: readonly ["comments", string];
            page: CommentPage;
            pageParam: number;
        }>>>;
        dehydratedState: DehydratedState;
        post: QueryHandle<(input: {
            slug: string;
        }) => QueryOptions<Flat<{
            data: Post;
            key: readonly ["post", string];
        }>>>;
    }
    loader
    (
    args: CreateServerLoaderArgs<Info & {
        module: typeof import("./blog.$slug");
        matches: Matches;
    }>
    args
    : Route.
    type Route.LoaderArgs = {
        request: Request;
        url: URL;
        params: {
            slug: string;
        };
        pattern: string;
        context: Readonly<RouterContextProvider>;
    }
    LoaderArgs
    ) {
    const const qc: QueryClient<never>qc =
    args: CreateServerLoaderArgs<Info & {
        module: typeof import("./blog.$slug");
        matches: Matches;
    }>
    args
    .context: Readonly<RouterContextProvider>context.get<QueryClient<never>>(context: RouterContext<QueryClient<never>>): QueryClient<never>get(const queryClientContext: RouterContext<QueryClient<never>>queryClientContext);
    const { const slug: stringslug } =
    args: CreateServerLoaderArgs<Info & {
        module: typeof import("./blog.$slug");
        matches: Matches;
    }>
    args
    .
    params: {
        slug: string;
    }
    params
    ;
    // Never waits: both 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 });
    const
    const post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>
    post
    = 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 });
    return {
    comments: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<Flat<{
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
    }>>>
    comments
    , dehydratedState: DehydratedStatedehydratedState: function dehydrate(client: QueryClient, options?: DehydrateOptions): DehydratedStatedehydrate(const qc: QueryClient<never>qc),
    post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>
    post
    };
    }
  3. Read them in components

    The route component wraps the page in HydrationBoundary, which writes the loader’s snapshot into the browser’s cache. useQuery takes the factory and a handle and returns a promise. use() suspends that subtree while its skeleton shows, so the rest of the page can stream in around it.

    From then on the cache keeps both up to date: it refetches stale data when the window regains focus and after a write invalidates it, and each component re-renders with the new data.

    Reading queries
    app/routes/blog.$slug.tsx
    export default function function PostPage(props: Route.ComponentProps): React.JSX.ElementPostPage(
    props: CreateComponentProps<Info & {
        module: typeof import("./blog.$slug");
        matches: Matches;
    }, false>
    props
    : Route.
    type Route.ComponentProps = {
        params: {
            slug: string;
        };
        matches: [{
            id: "root";
            params: Record<string, string | undefined>;
            pathname: string;
            loaderData: undefined;
            handle: unknown;
        }, {
            id: "routes/blog.$slug";
            params: Record<string, string | undefined>;
            pathname: string;
            loaderData: {
                comments: {
                    readonly input: {
                        slug: string;
                    };
                    readonly key: readonly undefined[];
                    readonly [factoryType]?: undefined;
                };
                dehydratedState: {
                    readonly queries: readonly {
                        readonly data?: undefined;
                        readonly effect?: boolean | undefined;
                        readonly key: readonly undefined[];
                        readonly meta?: {
                            [x: string]: undefined;
                        } | undefined;
                        readonly promise?: Promise<{
                            readonly data?: undefined;
                            readonly updatedAt: number;
                        }> | undefined;
                        readonly updatedAt: number;
                    }[];
                };
                post: {
                    ...;
                };
            };
            handle: unknown;
        }, ...({
            ...;
        } | undefined)[]];
    } & {
        ...;
    }
    ComponentProps
    ) {
    const {
    const comments: {
        readonly input: {
            slug: string;
        };
        readonly key: readonly undefined[];
        readonly [factoryType]?: undefined;
    }
    comments
    ,
    const dehydratedState: {
        readonly queries: readonly {
            readonly data?: undefined;
            readonly effect?: boolean | undefined;
            readonly key: readonly undefined[];
            readonly meta?: {
                [x: string]: undefined;
            } | undefined;
            readonly promise?: Promise<{
                readonly data?: undefined;
                readonly updatedAt: number;
            }> | undefined;
            readonly updatedAt: number;
        }[];
    }
    dehydratedState
    ,
    const post: {
        readonly input: {
            slug: string;
        };
        readonly key: readonly undefined[];
        readonly [factoryType]?: undefined;
    }
    post
    } =
    props: CreateComponentProps<Info & {
        module: typeof import("./blog.$slug");
        matches: Matches;
    }, false>
    props
    .
    loaderData: {
        comments: {
            readonly input: {
                slug: string;
            };
            readonly key: readonly undefined[];
            readonly [factoryType]?: undefined;
        };
        dehydratedState: {
            readonly queries: readonly {
                readonly data?: undefined;
                readonly effect?: boolean | undefined;
                readonly key: readonly undefined[];
                readonly meta?: {
                    [x: string]: undefined;
                } | undefined;
                readonly promise?: Promise<{
                    readonly data?: undefined;
                    readonly updatedAt: number;
                }> | undefined;
                readonly updatedAt: number;
            }[];
        };
        post: {
            ...;
        };
    }
    loaderData
    ;
    return ( <function HydrationBoundary(props: HydrationBoundaryProps): React.JSX.ElementHydrationBoundary state: DehydratedStatestate={
    const dehydratedState: {
        readonly queries: readonly {
            readonly data?: undefined;
            readonly effect?: boolean | undefined;
            readonly key: readonly undefined[];
            readonly meta?: {
                [x: string]: undefined;
            } | undefined;
            readonly promise?: Promise<{
                readonly data?: undefined;
                readonly updatedAt: number;
            }> | undefined;
            readonly updatedAt: number;
        }[];
    }
    dehydratedState
    }>
    <React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article> <const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense React.SuspenseProps.fallback?: React.ReactNodefallback={<function PostSkeleton(): React.ReactNodePostSkeleton />}> <
    function Post(props: {
        handle: QueryHandle<typeof postQuery>;
    }): React.JSX.Element
    Post
    handle: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>
    handle
    ={
    const post: {
        readonly input: {
            slug: string;
        };
        readonly key: readonly undefined[];
        readonly [factoryType]?: undefined;
    }
    post
    } />
    </const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense> <const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense React.SuspenseProps.fallback?: React.ReactNodefallback={<function CommentsSkeleton(): React.JSX.ElementCommentsSkeleton />}> <
    function PostComments(props: {
        handle: QueryHandle<typeof commentsQuery>;
    }): React.JSX.Element[]
    PostComments
    handle: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<Flat<{
        key: readonly ["comments", string];
        page: CommentPage;
        pageParam: number;
    }>>>
    handle
    ={
    const comments: {
        readonly input: {
            slug: string;
        };
        readonly key: readonly undefined[];
        readonly [factoryType]?: undefined;
    }
    comments
    } />
    </const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense> </React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article> </function HydrationBoundary(props: HydrationBoundaryProps): React.JSX.ElementHydrationBoundary> ); } function
    function Post(props: {
        handle: QueryHandle<typeof postQuery>;
    }): React.JSX.Element
    Post
    (
    props: {
        handle: QueryHandle<typeof postQuery>;
    }
    props
    : {
    handle: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>
    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
    > }) {
    const [postPromise] =
    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: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>, options?: (Options & {
        readonly throwOnError?: never;
    } & {
        readonly throwOnError?: undefined;
    }) | undefined): readonly [promise: Thenable<Post>, controls: QueryControls<...>] (+1 overload)
    useQuery
    (
    function postQuery(input: {
        slug: string;
    }): QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>
    postQuery
    ,
    props: {
        handle: QueryHandle<typeof postQuery>;
    }
    props
    .
    handle: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>
    handle
    );
    const postPromise: Thenable<Post>
    const const post: Postpost = use<Post>(usable: React.Usable<Post>): Postuse(const postPromise: Thenable<Post>postPromise); return <React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>{const post: Postpost.Post.title: stringtitle}</React.JSX.IntrinsicElements.h1: React.DetailedHTMLProps<React.HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>; }

Built around how React wants data

Fetch-on-render is an explicitly named escape hatch, not the default. Every page starts its queries before it renders.

Try it: prefetch, ensure or fetch

The demo runs in your browser against a fake server, with its queries, their requests and the cache shown next to it.

Standing on TanStack Query’s shoulders

The cache’s mechanics follow TanStack Query closely: query keys, deduping, staleness, garbage collection, retries, structural sharing, infinite pages, focus and reconnect refetching. If you know TanStack Query, you already know how raf-query caches. What changes is where queries start and how components read them.

TanStack Queryraf-query
queryClient.prefetchQuery()qc.prefetch(factory, input)
queryClient.ensureQueryData()qc.ensure(factory, input)
queryClient.fetchQuery()qc.fetch(factory, input)
useSuspenseQuery()use(useQuery(factory, handle)[0])
useInfiniteQuery()useQuery() with an infiniteQueryOptions factory
placeholderData: keepPreviousDatawhilePending: "previous"
useMutation()Actions + qc.invalidate()
invalidateQueries(filters)qc.invalidate(filter)

What we leave out

TanStack Query is mature and broad. raf-query is narrower: the same cache ideas, shaped so queries start in loaders and RSC. These are deliberate gaps, not unfinished ports.

  • useMutationActions, useOptimistic and useActionState already cover pending, optimistic and error state.
  • enabled / select / placeholderDataThe route owns the input; shape data in the queryFn or the component; whilePending keeps the previous page.
  • refetchOnMountMounting a reader never fetches. Loaders re-run on navigation.
  • Render-starting useQueryOnly useWaterfall may start a request in render — waterfalls stay explicit.
  • Persistence and other frameworksA React + Suspense experiment, not a multi-framework ecosystem.

Everything we leave out, and why