Integrations
React Router
Client-only apps start queries in clientLoader on the tab's client; server-rendered apps start them in loader, dehydrate the cache, and hydrate it with HydrationBoundary.
React Router apps load data in one of two places, and raf-query fits each differently. Find yours, then read its section. Each one stands on its own:
- Client-only: SPA Mode (
ssr: false) withclientLoader, or Data Mode'screateBrowserRouter. Loaders run in the browser, on the tab's oneQueryClient, and nothing is hydrated.prefetch,ensureandfetchwork as in Starting queries. - Server rendering: Framework Mode with
ssr: true(the default) andloader. Loaders run on the server with oneQueryClientper request, which a root middleware creates, and dehydrate it into the loader data.HydrationBoundarywrites it into the browser's client. Useprefetchandfetchthere:ensurewaits likefetch.
If you can choose the router, prefer TanStack Start. A link preloaded on hover runs its route's loaders in the browser, so the next page's queries start in the browser's cache and the click uses them. React Router's prefetch="intent" doesn't: see Prefetching links.
Client-only apps
SPA Mode
Turn off server rendering with ssr: false:
export default {
ssr?: boolean | undefinedssr: false,
} satisfies type Config = {
appDirectory?: string;
serverModuleFormat?: ServerModuleFormat;
future?: [keyof FutureConfig] extends [never] ? {
[key: string]: never;
} : Partial<FutureConfig>;
splitRouteModules?: SplitRouteModulesOption;
basename?: string;
buildDirectory?: string;
buildEnd?: BuildEndHook;
prerender?: PrerenderPaths | {
paths: PrerenderPaths;
concurrency?: number;
};
... 6 more ...;
allowedActionOrigins?: string[];
}
Config;Create the tab's QueryClient in a module, so loaders can import it, and provide it in the root route. Its HydrateFallback is what the page shows until the first clientLoader is done:
// Only in a client-only app: with server rendering, a module's client would
// be shared by every request.
export const const queryClient: QueryClient<never>queryClient = new new QueryClient<never>(config?: QueryClientConfig<never>): QueryClient<never>QueryClient({ Config.defaults?: QueryBehavior | undefineddefaults: { QueryBehavior.staleTime?: number | undefinedstaleTime: 5000 } });export default function function App(): React.JSX.ElementApp() {
return (
<function QueryClientProvider(props: {
readonly children?: ReactNode;
readonly client: QueryClient<unknown>;
}): React.JSX.Element
QueryClientProvider client: QueryClient<unknown>client={const queryClient: QueryClient<never>queryClient}>
<function Outlet(props: OutletProps): React.ReactElement | nullOutlet />
{import.meta.ImportMeta.env: ImportMetaEnvenv.ImportMetaEnv.DEV: booleanDEV ? <function QueryDevtools(props: QueryDevtoolsProps): React.JSX.Element | nullQueryDevtools /> : null}
</function QueryClientProvider(props: {
readonly children?: ReactNode;
readonly client: QueryClient<unknown>;
}): React.JSX.Element
QueryClientProvider>
);
}
// Rendered into index.html at build time, until the first clientLoader is done.
export function function HydrateFallback(): React.JSX.ElementHydrateFallback() {
return <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>Loading…</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>;
}Mount DevTools only when import.meta.env.DEV is true so Vite drops the panel from production builds.
Client loaders
Each clientLoader starts its queries on that client and returns the handles. There is no snapshot to dehydrate and no HydrationBoundary: the queries are already in the client the components read.
export async function function clientLoader(args: Route.ClientLoaderArgs): Promise<{
comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>;
post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>;
}>
clientLoader(args: CreateClientLoaderArgs<Info & {
module: typeof import("./posts.$slug");
matches: Matches;
}>
args: Route.type Route.ClientLoaderArgs = ClientDataFunctionArgs<{
slug: string;
}> & {
serverLoader: () => Promise<undefined>;
}
ClientLoaderArgs) {
const { const slug: stringslug } = args: CreateClientLoaderArgs<Info & {
module: typeof import("./posts.$slug");
matches: Matches;
}>
args.params: {
slug: string;
}
params;
// Doesn't wait: the comments stream in after the page renders.
const const comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>
comments = const queryClient: QueryClient<never>queryClient.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 });
// Waits on the first visit only. Coming back shows the cached post at
// once, and refetches it behind the page once it's stale.
const const post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
post = await const queryClient: QueryClient<never>queryClient.QueryClient<never>.ensure<(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;
}): Promise<QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>> (+1 overload)
ensure(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: QueryHandleWithData<(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("./posts.$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/posts.$slug";
params: Record<string, string | undefined>;
pathname: string;
loaderData: {
comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>;
post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>;
};
handle: unknown;
}, ...({
...;
} | undefined)[]];
} & {
...;
}
ComponentProps) {
const { const comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>
comments, const post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
post } = props: CreateComponentProps<Info & {
module: typeof import("./posts.$slug");
matches: Matches;
}, false>
props.loaderData: {
comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>;
post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>;
}
loaderData;
return (
<React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article>
<function Post(props: {
handle: QueryHandleWithData<typeof postQuery>;
}): React.JSX.Element
Post handle: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
handle={const post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
post} />
<const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense React.SuspenseProps.fallback?: React.ReactNodefallback={<function CommentsSkeleton(): React.JSX.ElementCommentsSkeleton />}>
<function Comments(props: {
handle: QueryHandle<typeof commentsQuery>;
}): React.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: React.ExoticComponent<React.SuspenseProps>Suspense>
</React.JSX.IntrinsicElements.article: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>article>
);
}
function function Post(props: {
handle: QueryHandleWithData<typeof postQuery>;
}): React.JSX.Element
Post(props: {
handle: QueryHandleWithData<typeof postQuery>;
}
props: { handle: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
handle: interface QueryHandleWithData<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandleWithData<typeof function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery> }) {
// The loader waited for the post, so this is the post, not a promise.
const [post] = useQuery<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>, undefined>(factory: (input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>, handle: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>, options?: (Options & {
readonly throwOnError?: never;
} & {
readonly throwOnError?: undefined;
}) | undefined): readonly [data: Post, controls: QueryControls<...>] (+1 overload)
useQuery(function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery, props: {
handle: QueryHandleWithData<typeof postQuery>;
}
props.handle: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
handle); 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>;
}export function function Comments(props: {
handle: QueryHandle<typeof commentsQuery>;
}): React.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 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} />);
}
- The client outlives navigations, so the verbs differ as in Starting queries:
ensureholds up only the first visit, and returning to a page shows the cached data at once, then refetches it behind the page once it's stale.fetchwaits whenever the data is stale. - React Router runs a route's
clientLoaderagain on every navigation to it. Data that is still fresh costs no request. - A module's client is safe only when nothing renders on the server. With
ssr: true, every request would share it: see Server rendering. - In Data Mode, a route's
loaderruns in the browser too: start queries in it the same way, on a client from a module.
Server rendering
Provider
One QueryClient per browser tab, in the root route's component:
export default function function App(): React.JSX.ElementApp() {
const [const queryClient: QueryClient<never>queryClient] = useState<QueryClient<never>>(initialState: QueryClient<never> | (() => QueryClient<never>)): [QueryClient<never>, React.Dispatch<React.SetStateAction<QueryClient<never>>>] (+1 overload)useState(
() => new new QueryClient<never>(config?: QueryClientConfig<never>): QueryClient<never>QueryClient({ Config.defaults?: QueryBehavior | undefineddefaults: { QueryBehavior.staleTime?: number | undefinedstaleTime: 5000 } }),
);
return (
<function QueryClientProvider(props: {
readonly children?: ReactNode;
readonly client: QueryClient<unknown>;
}): React.JSX.Element
QueryClientProvider client: QueryClient<unknown>client={const queryClient: QueryClient<never>queryClient}>
<function Outlet(props: OutletProps): React.ReactElement | nullOutlet />
{import.meta.ImportMeta.env: ImportMetaEnvenv.ImportMetaEnv.DEV: booleanDEV ? <function QueryDevtools(props: QueryDevtoolsProps): React.JSX.Element | nullQueryDevtools /> : null}
</function QueryClientProvider(props: {
readonly children?: ReactNode;
readonly client: QueryClient<unknown>;
}): React.JSX.Element
QueryClientProvider>
);
}Mount DevTools only when import.meta.env.DEV is true so Vite drops the panel from production builds. The example app always renders it, so the panel stays available in the deployed demo.
A client per request
A root middleware creates the request's QueryClient and puts it in a context:
import type { class QueryClient<TServices = never>QueryClient } from "@raf-query/react";
import { function createContext<T>(defaultValue?: T): RouterContext<T>createContext } from "react-router";
export const const queryClientContext: RouterContext<QueryClient<never>>queryClientContext = createContext<QueryClient<never>>(defaultValue?: QueryClient<never> | undefined): RouterContext<QueryClient<never>>createContext<class QueryClient<TServices = never>QueryClient>();export const const middleware: CreateServerMiddlewareFunction<Info & {
module: typeof import("./root");
matches: Matches;
}>[]
middleware: interface Array<T>Array<Route.type Route.MiddlewareFunction = (args: ServerDataFunctionArgs<{
slug?: undefined;
} | {
slug: string;
} | {
slug: string;
}>, next: MiddlewareNextFunction<Response>) => MaybePromise<void | Response>
MiddlewareFunction> = [
({ context: Readonly<RouterContextProvider>context }) => {
context: Readonly<RouterContextProvider>context.set<RouterContext<QueryClient<never>>>(context: RouterContext<QueryClient<never>>, value: QueryClient<never>): voidset(const queryClientContext: RouterContext<QueryClient<never>>queryClientContext, new new QueryClient<never>(config?: QueryClientConfig<never>): QueryClient<never>QueryClient());
},
];
Server middleware runs for the document request and for each client navigation's data request, before any loader. Every loader of that request reads the same client from args.context, so nested routes share one cache: React Router runs the matched routes' loaders in parallel, and a query that a layout's loader and a child route's loader both start is requested once.
Server loaders
Each loader takes the request's client from the context, starts every query the route needs, and returns the handles with a dehydrated snapshot of the cache:
export async function function loader(args: Route.LoaderArgs): Promise<{
comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>;
dehydratedState: DehydratedState;
post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>;
}>
loader(args: CreateServerLoaderArgs<Info & {
module: typeof import("./posts.$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("./posts.$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("./posts.$slug");
matches: Matches;
}>
args.params: {
slug: string;
}
params;
// Doesn't wait: the comments stream in after 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 });
// Waits, so the post is in the page's first HTML.
const const post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
post = await const qc: QueryClient<never>qc.QueryClient<never>.fetch<(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;
}): Promise<QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>> (+1 overload)
fetch(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: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
post,
};
}
The route component wraps what it renders in a HydrationBoundary with that snapshot, and passes the handles down:
export default function function PostPage(props: Route.ComponentProps): React.JSX.ElementPostPage(props: CreateComponentProps<Info & {
module: typeof import("./posts.$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/posts.$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 data: {
readonly body: string;
readonly slug: string;
readonly title: string;
};
readonly input: {
slug: string;
};
readonly key: readonly undefined[];
readonly [factoryType]?: undefined;
}
post } = props: CreateComponentProps<Info & {
module: typeof import("./posts.$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>
<function Post(props: {
handle: QueryHandleWithData<typeof postQuery>;
}): React.JSX.Element
Post handle: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
handle={const post: {
readonly data: {
readonly body: string;
readonly slug: string;
readonly title: string;
};
readonly input: {
slug: string;
};
readonly key: readonly undefined[];
readonly [factoryType]?: undefined;
}
post} />
<const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense React.SuspenseProps.fallback?: React.ReactNodefallback={<function CommentsSkeleton(): React.JSX.ElementCommentsSkeleton />}>
<function Comments(props: {
handle: QueryHandle<typeof commentsQuery>;
}): React.JSX.Element[]
Comments 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: QueryHandleWithData<typeof postQuery>;
}): React.JSX.Element
Post(props: {
handle: QueryHandleWithData<typeof postQuery>;
}
props: { handle: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
handle: interface QueryHandleWithData<TFactory extends QueryFactory = (input: unknown) => FactoryResult>QueryHandleWithData<typeof function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery> }) {
// The loader waited for the post, so this is the post, not a promise.
const [post] = useQuery<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>, undefined>(factory: (input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>, handle: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>, options?: (Options & {
readonly throwOnError?: never;
} & {
readonly throwOnError?: undefined;
}) | undefined): readonly [data: Post, controls: QueryControls<...>] (+1 overload)
useQuery(function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery, props: {
handle: QueryHandleWithData<typeof postQuery>;
}
props.handle: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
handle); 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>;
}export function function Comments(props: {
handle: QueryHandle<typeof commentsQuery>;
}): React.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 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} />);
}
- Create the server's client per request, in the middleware. A module-level client would share data between requests and users.
dehydrate(qc)takes entries with data and entries still loading. React Router streams promises in loader data, so aprefetchthat is still loading resolves in the browser when the server's request does, and the browser doesn't fetch it again.- With nested routes, a loader's snapshot holds every entry the client has when the loader returns, other loaders' queries included. The browser keeps the newer copy of an entry, so the overlap only adds to the payload.
HydrationBoundarywrites the entries into the provider's client: entries the browser doesn't have yet while it renders, so its children read them, and the rest once it commits. The browser keeps whichever copy of an entry is newer. See SSR and hydration.await qc.fetch(...)returns a handle with thedata, for deciding on a redirect or building another query's input in the loader. Pass it to components as aQueryHandleWithData:useQueryreturns the data without a promise, and follows later refetches. See Handles.- Use
prefetchandfetchin aloader, notensure. Aloaderruns on the server for every navigation, client-side ones included, and its client starts empty, soensurewaits for the request every time, exactly likefetch. See Starting queries.
What the integration is
HydrationBoundary, from @raf-query/react, takes the dehydrated state. The cache verbs, handles and useQuery are the same as everywhere else. React Router has no hooks for streaming a cache with the page, so each loader returns its snapshot in its data. Options the other integrations take as dehydrateOptions, such as keepErrors and filter, go to dehydrate(qc, options).
react-router isn't a peer dependency: raf-query imports nothing from it.
Server and browser requests
Loaders run on the server, for the first page load and for each client navigation, so their client starts with an empty cache every time. Components refetch from the browser. A factory's queryFn runs in both places, and has to reach the same data as the same user in both:
- On the server, a relative
fetch("/api/...")has no page origin to resolve against, and the browser's cookies aren't sent unless you forward them fromargs.request, the page'sRequest. - In the browser,
fetchsends the user's cookies to your origin.
examples/react-router serves its API from a resource route and gives its API client the page request's cookies while a loader runs, in app/settings.ts and app/api.ts. Loaders call the API in the same process, and the browser calls it over HTTP.
Pending UI
While a link navigation is loading the next route, show pending state with React Router, not useIsFetching. useNavigation reports a navigation in progress, and NavLink reports its own:
function function NavigationSpinner(): React.JSX.Element | nullNavigationSpinner() {
const const navigation: UseNavigationResultnavigation = function useNavigation(): UseNavigationResultuseNavigation();
return const navigation: UseNavigationResultnavigation.state: "idle" | "loading" | "submitting"state === "loading" ? <function Spinner(): ReactNodeSpinner /> : null;
}
<const NavLink: React.ForwardRefExoticComponent<NavLinkProps & React.RefAttributes<HTMLAnchorElement>>NavLink to: Toto="/posts/hello">
{({ isPending: booleanisPending }) => (isPending: booleanisPending ? "Hello (loading…)" : "Hello")}
</const NavLink: React.ForwardRefExoticComponent<NavLinkProps & React.RefAttributes<HTMLAnchorElement>>NavLink>;Per-query background refetches after the page has rendered use controls.isFetching. See Background fetching indicators.
Prefetching links
prefetch="intent" on a Link or NavLink preloads the route's modules when the user hovers or focuses the link ("render" does it when the link renders, "viewport" when it scrolls into view, and "none", the default, never). With either kind of loader, the click doesn't start from the prefetch's queries:
- A server
loader: the prefetch also requests the loader data, which runs the loaders on the server, but React Router doesn't keep the response. The click requests the loader data again, and the loaders and their queries run a second time. What the prefetch saves is loading the route's code. - A
clientLoader: the prefetch doesn't run it; it runs on the click. To start its queries earlier, callqueryClient.prefetchfrom the link'sonPointerEnter, as in Preloading on intent.
In TanStack Router, the navigation reuses a route preloaded in the last 30 seconds, or joins its loader if it's still running (Preloading). Its loaders run in the browser, so the queries a preload starts land in the browser's cache, and the navigation's loader finds them there even once the preload is stale. See TanStack Start.
Search params
Read search params from args.request.url in the loader or clientLoader, validate them, and pass them as the input. React Router runs the loader again when they change. See URL state and pagination for what to show while it loads.
Mutations
Write from the browser, then invalidate the queries the write changed with useQueryClient().invalidate(...), as in Mutations. React Router actions work too: after one, React Router runs the page's loaders again. A server loader's new snapshot carries the new data. A clientLoader reads the tab's cache, which may still hold the old data as fresh, so await queryClient.invalidate(...) in the clientAction before it returns.
Example
examples/react-router is the same issue tracker as the TanStack Start and Next.js examples, so the integrations can be compared side by side.