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
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 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>;
}
function function PostComments(props: {
handle: QueryHandle<typeof commentsQuery>;
}): React.JSX.Element[]
PostComments(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 const pages: InfiniteData<{
page: CommentPage;
pageParam: number;
}>
pages = use<InfiniteData<{
page: CommentPage;
pageParam: number;
}>>(usable: React.Usable<InfiniteData<{
page: CommentPage;
pageParam: number;
}>>): InfiniteData<{
page: CommentPage;
pageParam: number;
}>
use(const pagesPromise: Thenable<InfiniteData<{
page: CommentPage;
pageParam: number;
}>>
pagesPromise);
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<React.JSX.Element>(callbackfn: (value: PostComment, index: number, array: PostComment[]) => React.JSX.Element, thisArg?: any): React.JSX.Element[]map(function renderComment(comment: PostComment): React.JSX.ElementrenderComment);
}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>
);
}"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 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>;
}"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); 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} />);
}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 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>;
}
function function PostComments(props: {
handle: QueryHandle<typeof commentsQuery>;
}): React.JSX.Element[]
PostComments(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 const pages: InfiniteData<{
page: CommentPage;
pageParam: number;
}>
pages = use<InfiniteData<{
page: CommentPage;
pageParam: number;
}>>(usable: React.Usable<InfiniteData<{
page: CommentPage;
pageParam: number;
}>>): InfiniteData<{
page: CommentPage;
pageParam: number;
}>
use(const pagesPromise: Thenable<InfiniteData<{
page: CommentPage;
pageParam: number;
}>>
pagesPromise);
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<React.JSX.Element>(callbackfn: (value: PostComment, index: number, array: PostComment[]) => React.JSX.Element, thisArg?: any): React.JSX.Element[]map((comment: PostCommentcomment) => <function Comment(props: {
comment: PostComment;
}): React.ReactNode
Comment React.Attributes.key?: React.Key | null | undefinedkey={comment: PostCommentcomment.PostComment.id: stringid} comment: PostCommentcomment={comment: PostCommentcomment} />);
}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.
Define query factories
Query factoriesA query factory is a function of the query’s input that returns
queryOptions, as in TanStack Query: a key, aqueryFn, and options likestaleTime. Comments load a page at a time, socommentsQueryusesinfiniteQueryOptions.getPostis 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
queryFnto the component that reads it.The input has to be serializable: it’s what a handle carries from the server to the browser.
src/queries.tsimport {infiniteQueryOptions,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<...>>>queryOptions } from "@raf-query/react"; import { type CommentPage,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>>>listComments } from "./api.ts"; import {function listComments(input: { cursor: number; slug: string; }): Promise<CommentPage>getPost } from "./posts.functions.ts"; export functionconst getPost: RequiredFetcher<undefined, ZodObject<{ slug: ZodString; }, $strip>, Promise<Post>>postQuery(function postQuery(input: { slug: string; }): QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>input: {input: { slug: string; }slug: stringslug: string }) { returnqueryOptions({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]; }>>queryKey: readonly ["post", string]queryKey: ["post",input.input: { slug: string; }slug: stringslug],queryFn: () =>function queryFn(context: QueryFunctionContext): QueryFnResult<{ data: Post; error: unknown; key: readonly ["post", string]; services: never; }>getPost({function getPost(opts: RequiredFetcherDataOptions<undefined, ZodObject<{ slug: ZodString; }, $strip>>): Promise<Post>data:RequiredFetcherDataOptions<undefined, ZodObject<{ slug: ZodString; }, $strip>>.data: { slug: string; }input }), }); } export functioninput: { slug: string; }commentsQuery(function commentsQuery(input: { slug: string; }): InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>input: {input: { slug: string; }slug: stringslug: string }) { returninfiniteQueryOptions({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; }>>queryKey: readonly ["comments", string]queryKey: ["comments",input.input: { slug: string; }slug: stringslug],queryFn: (function queryFn(context: PageFunctionContext<number>): QueryFnResult<{ data: CommentPage; error: unknown; services: never; }>context: PageFunctionContext<number>context) =>listComments({function listComments(input: { cursor: number; slug: string; }): Promise<CommentPage>cursor: numbercursor:context: PageFunctionContext<number>context.PageFunctionContext<number>.pageParam: numberpageParam,slug: stringslug:input.input: { slug: string; }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, }); }Start them in the route loader
Starting queriesThe 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.
prefetchnever 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
ensurewhen the route can’t lay out without the data. Preferprefetchwhen a component will read it under Suspense — the usual shape, and what the example app does for streamed parts of the page.src/routes/blog.$slug.tsxexport constRoute =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>createFileRoute("/blog/$slug")({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<...>loader: (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<...>> | undefinedargs) => { constargs: 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>const qc: QueryClient<never>qc =args.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>context.LoaderFnContext<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 7 more ..., undefined>.context: { queryClient: QueryClient; }queryClient: QueryClient<never>queryClient; const {const slug: stringslug } =args.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>params; // Never waits: both load while the page renders. constLoaderFnContext<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, {}, ... 5 more ..., undefined>, ... 7 more ..., undefined>.params: { slug: string; }comments =const comments: QueryHandle<(input: { slug: string; }) => InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>>const qc: QueryClient<never>qc.prefetch(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; }>>>commentsQuery, {function commentsQuery(input: { slug: string; }): InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>slug: stringslug }); constpost =const post: QueryHandle<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>const qc: QueryClient<never>qc.prefetch(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]; }>>>postQuery, {function postQuery(input: { slug: string; }): QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>slug: stringslug }); return {comments,comments: QueryHandle<(input: { slug: string; }) => InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>>post }; },post: QueryHandle<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>UpdatableRouteOptionsExtensions.component?: RouteComponent | undefinedcomponent:function PostPage(): React.JSX.ElementPostPage, });Read them in components
Reading queriesuseQuerytakes 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.
src/routes/blog.$slug.tsxfunctionfunction PostPage(): React.JSX.ElementPostPage() { constdata =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]; }>>>; }Route.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>useLoaderData(); return ( <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) => { ...; }React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article> <const Suspense: React.ExoticComponent<React.SuspenseProps>SuspenseReact.SuspenseProps.fallback?: React.ReactNodefallback={<function PostSkeleton(): React.ReactNodePostSkeleton />}> <Postfunction Post(props: { handle: QueryHandle<typeof postQuery>; }): React.JSX.Elementhandle={handle: QueryHandle<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>data.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]; }>>>; }post} /> </post: QueryHandle<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense> <const Suspense: React.ExoticComponent<React.SuspenseProps>SuspenseReact.SuspenseProps.fallback?: React.ReactNodefallback={<function CommentsSkeleton(): React.JSX.ElementCommentsSkeleton />}> <PostCommentsfunction PostComments(props: { handle: QueryHandle<typeof commentsQuery>; }): React.JSX.Element[]handle={handle: QueryHandle<(input: { slug: string; }) => InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>>data.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]; }>>>; }comments} /> </comments: QueryHandle<(input: { slug: string; }) => InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>>const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense> </React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article> ); } functionPost(function Post(props: { handle: QueryHandle<typeof postQuery>; }): React.JSX.Elementprops: {props: { handle: QueryHandle<typeof postQuery>; }handle:handle: QueryHandle<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>interface QueryHandle<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandle<typeofpostQuery> }) { const [postPromise] =function postQuery(input: { slug: string; }): QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>useQuery(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)postQuery,function postQuery(input: { slug: string; }): QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>props.props: { handle: QueryHandle<typeof postQuery>; }handle); consthandle: QueryHandle<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>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>; } functionPostComments(function PostComments(props: { handle: QueryHandle<typeof commentsQuery>; }): React.JSX.Element[]props: {props: { handle: QueryHandle<typeof commentsQuery>; }handle:handle: QueryHandle<(input: { slug: string; }) => InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>>interface QueryHandle<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandle<typeofcommentsQuery> }) { // May still be loading. If so, use() suspends until it lands. const [function commentsQuery(input: { slug: string; }): InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>pagesPromise] =const pagesPromise: Thenable<InfiniteData<{ page: CommentPage; pageParam: number; }>>useQuery(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)commentsQuery,function commentsQuery(input: { slug: string; }): InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>props.props: { handle: QueryHandle<typeof commentsQuery>; }handle); consthandle: QueryHandle<(input: { slug: string; }) => InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>>pages =const pages: InfiniteData<{ page: CommentPage; pageParam: number; }>use(use<InfiniteData<{ page: CommentPage; pageParam: number; }>>(usable: React.Usable<InfiniteData<{ page: CommentPage; pageParam: number; }>>): InfiniteData<{ page: CommentPage; pageParam: number; }>pagesPromise); returnconst pagesPromise: Thenable<InfiniteData<{ page: CommentPage; pageParam: number; }>>pages.const pages: InfiniteData<{ page: CommentPage; pageParam: number; }>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<React.JSX.Element>(callbackfn: (value: PostComment, index: number, array: PostComment[]) => React.JSX.Element, thisArg?: any): React.JSX.Element[]map(function renderComment(comment: PostComment): React.JSX.ElementrenderComment); }
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.
Define query factories
Query factoriesA query factory is a function of the query’s input that returns
queryOptions, as in TanStack Query: a key, aqueryFn, and options likestaleTime. Comments load a page at a time, socommentsQueryusesinfiniteQueryOptions.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
queryFnto the component that reads it.The input has to be serializable: it’s what a handle carries from the server to the browser.
src/queries.tsimport {infiniteQueryOptions,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<...>>>queryOptions } from "@raf-query/react"; import {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>>>function getPost(slug: string): Promise<Post>getPost,listComments } from "./api.ts"; export functionfunction listComments(input: { cursor: number; slug: string; }): Promise<CommentPage>postQuery(function postQuery(input: { slug: string; }): QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>input: {input: { slug: string; }slug: stringslug: string }) { returnqueryOptions({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]; }>>queryKey: readonly ["post", string]queryKey: ["post",input.input: { slug: string; }slug: stringslug],queryFn: () =>function queryFn(context: QueryFunctionContext): QueryFnResult<{ data: Post; error: unknown; key: readonly ["post", string]; services: never; }>function getPost(slug: string): Promise<Post>getPost(input.input: { slug: string; }slug: stringslug), }); } export functioncommentsQuery(function commentsQuery(input: { slug: string; }): InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>input: {input: { slug: string; }slug: stringslug: string }) { returninfiniteQueryOptions({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; }>>queryKey: readonly ["comments", string]queryKey: ["comments",input.input: { slug: string; }slug: stringslug],queryFn: (function queryFn(context: PageFunctionContext<number>): QueryFnResult<{ data: CommentPage; error: unknown; services: never; }>context: PageFunctionContext<number>context) =>listComments({function listComments(input: { cursor: number; slug: string; }): Promise<CommentPage>cursor: numbercursor:context: PageFunctionContext<number>context.PageFunctionContext<number>.pageParam: numberpageParam,slug: stringslug:input.input: { slug: string; }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, }); }Start them in a server component
Starting queriesA 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.
prefetchnever 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.HydrationBoundarystreams the cache to the browser next to those handles, so nothing is fetched twice.Use
fetchwhen the server component itself renders the data. Preferprefetchwhen a client component will read it under Suspense — the usual App Router shape, and what the example app does.src/app/(blog)/posts/[slug]/page.tsxexport default async functionPage(function Page(props: { params: Promise<{ slug: string; }>; }): Promise<JSX.Element>props: {props: { params: Promise<{ slug: string; }>; }params:params: Promise<{ slug: string; }>interface Promise<T>Promise<{slug: stringslug: string }>; }) { const {const slug: stringslug } = awaitprops.props: { params: Promise<{ slug: string; }>; }params; constparams: Promise<{ slug: string; }>const qc: QueryClient<never>qc =function getQueryClient(): QueryClient<never>getQueryClient(); // Never waits: both load while the page streams. constcomments =const comments: QueryHandle<(input: { slug: string; }) => InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>>const qc: QueryClient<never>qc.prefetch(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; }>>>commentsQuery, {function commentsQuery(input: { slug: string; }): InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>slug: stringslug }); constpost =const post: QueryHandle<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>const qc: QueryClient<never>qc.prefetch(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]; }>>>postQuery, {function postQuery(input: { slug: string; }): QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>slug: stringslug }); return ( <function HydrationBoundary(props: HydrationBoundaryProps): JSX.ElementHydrationBoundaryclient: QueryClient<never>client={const qc: QueryClient<never>qc}> <JSX.IntrinsicElements.article: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>article> <const Suspense: ExoticComponent<SuspenseProps>SuspenseSuspenseProps.fallback?: ReactNodefallback={<function PostSkeleton(): JSX.ElementPostSkeleton />}> <Postfunction Post(props: { handle: QueryHandle<typeof postQuery>; }): JSX.Elementhandle={handle: QueryHandle<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>post} /> </const post: QueryHandle<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>const Suspense: ExoticComponent<SuspenseProps>Suspense> <const Suspense: ExoticComponent<SuspenseProps>SuspenseSuspenseProps.fallback?: ReactNodefallback={<function CommentsSkeleton(): JSX.ElementCommentsSkeleton />}> <Commentsfunction Comments(props: { handle: QueryHandle<typeof commentsQuery>; }): JSX.Element[]handle={handle: QueryHandle<(input: { slug: string; }) => InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>>comments} /> </const comments: QueryHandle<(input: { slug: string; }) => InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>>const Suspense: ExoticComponent<SuspenseProps>Suspense> </JSX.IntrinsicElements.article: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>article> </function HydrationBoundary(props: HydrationBoundaryProps): JSX.ElementHydrationBoundary> ); }Read them in client components
Reading queriesClient components live in their own files, marked with a
use clientdirective.useQuerytakes 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.
src/app/(blog)/components/post.tsx"use client"; export functionPost(function Post(props: { handle: QueryHandle<typeof postQuery>; }): JSX.Elementprops: {props: { handle: QueryHandle<typeof postQuery>; }handle:handle: QueryHandle<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>interface QueryHandle<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandle<typeofpostQuery> }) { const [postPromise] =function postQuery(input: { slug: string; }): QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>useQuery(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)postQuery,function postQuery(input: { slug: string; }): QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>props.props: { handle: QueryHandle<typeof postQuery>; }handle); consthandle: QueryHandle<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>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 functionComments(function Comments(props: { handle: QueryHandle<typeof commentsQuery>; }): JSX.Element[]props: {props: { handle: QueryHandle<typeof commentsQuery>; }handle:handle: QueryHandle<(input: { slug: string; }) => InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>>interface QueryHandle<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandle<typeofcommentsQuery> }) { // May still be loading. If so, use() suspends until it lands. const [function commentsQuery(input: { slug: string; }): InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>pagesPromise] =const pagesPromise: Thenable<InfiniteData<{ page: CommentPage; pageParam: number; }>>useQuery(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)commentsQuery,function commentsQuery(input: { slug: string; }): InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>props.props: { handle: QueryHandle<typeof commentsQuery>; }handle); const pages =handle: QueryHandle<(input: { slug: string; }) => InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>>use(use<InfiniteData<{ page: CommentPage; pageParam: number; }>>(usable: Usable<InfiniteData<{ page: CommentPage; pageParam: number; }>>): InfiniteData<{ page: CommentPage; pageParam: number; }>pagesPromise); returnconst pagesPromise: Thenable<InfiniteData<{ page: CommentPage; pageParam: number; }>>pages.const pages: InfiniteData<{ page: CommentPage; pageParam: number; }>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) => <Commentfunction Comment(props: { comment: PostComment; }): React.ReactNodeAttributes.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.
Define query factories
Query factoriesA query factory is a function of the query’s input that returns
queryOptions, as in TanStack Query: a key, aqueryFn, and options likestaleTime. Comments load a page at a time, socommentsQueryusesinfiniteQueryOptions.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
queryFnto the component that reads it.The input has to be serializable: it’s what a handle carries from the server to the browser.
app/queries.tsimport {infiniteQueryOptions,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<...>>>queryOptions } from "@raf-query/react"; import {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>>>function getPost(slug: string): Promise<Post>getPost,listComments } from "./api.ts"; export functionfunction listComments(input: { cursor: number; slug: string; }): Promise<CommentPage>postQuery(function postQuery(input: { slug: string; }): QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>input: {input: { slug: string; }slug: stringslug: string }) { returnqueryOptions({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]; }>>queryKey: readonly ["post", string]queryKey: ["post",input.input: { slug: string; }slug: stringslug],queryFn: () =>function queryFn(context: QueryFunctionContext): QueryFnResult<{ data: Post; error: unknown; key: readonly ["post", string]; services: never; }>function getPost(slug: string): Promise<Post>getPost(input.input: { slug: string; }slug: stringslug), }); } export functioncommentsQuery(function commentsQuery(input: { slug: string; }): InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>input: {input: { slug: string; }slug: stringslug: string }) { returninfiniteQueryOptions({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; }>>queryKey: readonly ["comments", string]queryKey: ["comments",input.input: { slug: string; }slug: stringslug],queryFn: (function queryFn(context: PageFunctionContext<number>): QueryFnResult<{ data: CommentPage; error: unknown; services: never; }>context: PageFunctionContext<number>context) =>listComments({function listComments(input: { cursor: number; slug: string; }): Promise<CommentPage>cursor: numbercursor:context: PageFunctionContext<number>context.PageFunctionContext<number>.pageParam: numberpageParam,slug: stringslug:input.input: { slug: string; }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, }); }Start them in the route loader
React RouterReact 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.
prefetchnever 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 withdehydrate(qc), and React Router streams the queries still loading to the browser, so nothing is fetched twice.Use
fetchwhen the route can’t lay out without the data. Preferprefetchwhen a component will read it under Suspense — the usual shape, and what the example app does for streamed parts of the page.app/routes/blog.$slug.tsxexport functionloader(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]; }>>>; }args: Route.args: CreateServerLoaderArgs<Info & { module: typeof import("./blog.$slug"); matches: Matches; }>LoaderArgs) { consttype Route.LoaderArgs = { request: Request; url: URL; params: { slug: string; }; pattern: string; context: Readonly<RouterContextProvider>; }const qc: QueryClient<never>qc =args.args: CreateServerLoaderArgs<Info & { module: typeof import("./blog.$slug"); matches: Matches; }>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.args: CreateServerLoaderArgs<Info & { module: typeof import("./blog.$slug"); matches: Matches; }>params; // Never waits: both load while the page renders. constparams: { slug: string; }comments =const comments: QueryHandle<(input: { slug: string; }) => InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>>const qc: QueryClient<never>qc.prefetch(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; }>>>commentsQuery, {function commentsQuery(input: { slug: string; }): InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>slug: stringslug }); constpost =const post: QueryHandle<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>const qc: QueryClient<never>qc.prefetch(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]; }>>>postQuery, {function postQuery(input: { slug: string; }): QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>slug: stringslug }); return {comments,comments: QueryHandle<(input: { slug: string; }) => InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>>dehydratedState: DehydratedStatedehydratedState:function dehydrate(client: QueryClient, options?: DehydrateOptions): DehydratedStatedehydrate(const qc: QueryClient<never>qc),post }; }post: QueryHandle<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>Read them in components
Reading queriesThe route component wraps the page in
HydrationBoundary, which writes the loader’s snapshot into the browser’s cache.useQuerytakes 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.
app/routes/blog.$slug.tsxexport default functionfunction PostPage(props: Route.ComponentProps): React.JSX.ElementPostPage(props: Route.props: CreateComponentProps<Info & { module: typeof import("./blog.$slug"); matches: Matches; }, false>ComponentProps) { const {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)[]]; } & { ...; }comments,const comments: { readonly input: { slug: string; }; readonly key: readonly undefined[]; readonly [factoryType]?: undefined; }dehydratedState,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; }[]; }post } =const post: { readonly input: { slug: string; }; readonly key: readonly undefined[]; readonly [factoryType]?: undefined; }props.props: CreateComponentProps<Info & { module: typeof import("./blog.$slug"); matches: Matches; }, false>loaderData; return ( <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: { ...; }; }function HydrationBoundary(props: HydrationBoundaryProps): React.JSX.ElementHydrationBoundarystate: DehydratedStatestate={dehydratedState}> <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; }[]; }React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article> <const Suspense: React.ExoticComponent<React.SuspenseProps>SuspenseReact.SuspenseProps.fallback?: React.ReactNodefallback={<function PostSkeleton(): React.ReactNodePostSkeleton />}> <Postfunction Post(props: { handle: QueryHandle<typeof postQuery>; }): React.JSX.Elementhandle={handle: QueryHandle<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>post} /> </const post: { readonly input: { slug: string; }; readonly key: readonly undefined[]; readonly [factoryType]?: undefined; }const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense> <const Suspense: React.ExoticComponent<React.SuspenseProps>SuspenseReact.SuspenseProps.fallback?: React.ReactNodefallback={<function CommentsSkeleton(): React.JSX.ElementCommentsSkeleton />}> <PostCommentsfunction PostComments(props: { handle: QueryHandle<typeof commentsQuery>; }): React.JSX.Element[]handle={handle: QueryHandle<(input: { slug: string; }) => InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>>comments} /> </const comments: { readonly input: { slug: string; }; readonly key: readonly undefined[]; readonly [factoryType]?: undefined; }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> ); } functionPost(function Post(props: { handle: QueryHandle<typeof postQuery>; }): React.JSX.Elementprops: {props: { handle: QueryHandle<typeof postQuery>; }handle:handle: QueryHandle<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>interface QueryHandle<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandle<typeofpostQuery> }) { const [postPromise] =function postQuery(input: { slug: string; }): QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>useQuery(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)postQuery,function postQuery(input: { slug: string; }): QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>props.props: { handle: QueryHandle<typeof postQuery>; }handle); consthandle: QueryHandle<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>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>; } functionPostComments(function PostComments(props: { handle: QueryHandle<typeof commentsQuery>; }): React.JSX.Element[]props: {props: { handle: QueryHandle<typeof commentsQuery>; }handle:handle: QueryHandle<(input: { slug: string; }) => InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>>interface QueryHandle<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandle<typeofcommentsQuery> }) { // May still be loading. If so, use() suspends until it lands. const [function commentsQuery(input: { slug: string; }): InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>pagesPromise] =const pagesPromise: Thenable<InfiniteData<{ page: CommentPage; pageParam: number; }>>useQuery(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)commentsQuery,function commentsQuery(input: { slug: string; }): InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>props.props: { handle: QueryHandle<typeof commentsQuery>; }handle); consthandle: QueryHandle<(input: { slug: string; }) => InfiniteQueryOptions<Flat<{ key: readonly ["comments", string]; page: CommentPage; pageParam: number; }>>>pages =const pages: InfiniteData<{ page: CommentPage; pageParam: number; }>use(use<InfiniteData<{ page: CommentPage; pageParam: number; }>>(usable: React.Usable<InfiniteData<{ page: CommentPage; pageParam: number; }>>): InfiniteData<{ page: CommentPage; pageParam: number; }>pagesPromise); returnconst pagesPromise: Thenable<InfiniteData<{ page: CommentPage; pageParam: number; }>>pages.const pages: InfiniteData<{ page: CommentPage; pageParam: number; }>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<React.JSX.Element>(callbackfn: (value: PostComment, index: number, array: PostComment[]) => React.JSX.Element, thisArg?: any): React.JSX.Element[]map((comment: PostCommentcomment) => <Commentfunction Comment(props: { comment: PostComment; }): React.ReactNodeReact.Attributes.key?: React.Key | null | undefinedkey={comment: PostCommentcomment.PostComment.id: stringid}comment: PostCommentcomment={comment: PostCommentcomment} />); }
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.
- Fetch where React recommendsRoute loaders and server components start every query a page needs, in parallel, before anything renders.
- Small, serializable handlesA handle is a key and an input, plus the data once ensure or fetch has loaded it. It crosses the loader, the server and the client.
- Promises in, use() outuseQuery returns a stable promise; unwrap it with use() wherever the data is needed, even in a child.
- The URL is the inputFilters, sort and pages live in the URL. whilePending picks between suspending and keeping the previous page.
- Actions for writesNo useMutation: write in form actions, then invalidate. React's useOptimistic and useActionState do the rest.
- Streaming SSR and RSCThe server's cache streams to the browser as queries settle, in TanStack Start, React Router and Next.js App Router.
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 Query | raf-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: keepPreviousData | whilePending: "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.