Skip to content

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

raf-query

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:

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:

react-router.config.ts
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:

app/query-client.ts
// 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 } });
app/root.tsx
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.

app/routes/posts.$slug.tsx
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
};
}
app/routes/posts.$slug.tsx
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
);
const post: Post
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>; }
app/components/comments.tsx
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: ensure holds 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. fetch waits whenever the data is stale.
  • React Router runs a route's clientLoader again 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 loader runs 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:

app/root.tsx
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:

app/query-client.ts
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>();
app/root.tsx
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:

app/routes/posts.$slug.tsx
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:

app/routes/posts.$slug.tsx
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
);
const post: Post
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>; }
app/components/comments.tsx
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 a prefetch that 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.
  • HydrationBoundary writes 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 the data, for deciding on a redirect or building another query's input in the loader. Pass it to components as a QueryHandleWithData: useQuery returns the data without a promise, and follows later refetches. See Handles.
  • Use prefetch and fetch in a loader, not ensure. A loader runs on the server for every navigation, client-side ones included, and its client starts empty, so ensure waits for the request every time, exactly like fetch. 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 from args.request, the page's Request.
  • In the browser, fetch sends 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.

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, call queryClient.prefetch from the link's onPointerEnter, 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.