Skip to content

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

raf-query

Guides

Request waterfalls

What a request waterfall is, why it's the biggest performance problem in data fetching, and how loaders, routes and backends remove them.

A request waterfall is a request that can't start until another one has finished. Each level of the waterfall costs a full round trip, and on a slow connection a round trip can be hundreds of milliseconds. Removing waterfalls is usually the single most effective thing you can do for an app's loading time.

raf-query is designed around this. Queries start in route loaders and server components, where everything a page needs is known up front, so they start together:

1. |-> Post
1. |-> Comments
1. |-> Author

instead of one after the other as each component renders and discovers what it needs:

1. |-> Post
2.   |-> Comments
3.     |-> Author

The demo below runs the same page both ways. Open a post with each strategy and watch the Queries panel: with the loader, all three requests load at once; in render, they load one after another.

Fetch-on-render

The classic waterfall comes from fetching where data is used. A component fetches its data, suspends, and only renders its children, which fetch their own data, once that request is done:

function 
function Post(props: {
    slug: string;
}): React.JSX.Element
Post
(
props: {
    slug: string;
}
props
: { slug: stringslug: string }) {
// Fetched during render: nothing below starts until this resolves. const
const post: {
    title: string;
}
post
=
function useSuspenseFetch(url: string): {
    title: string;
}
useSuspenseFetch
(`/posts/${
props: {
    slug: string;
}
props
.slug: stringslug}`);
return ( <> <
function PostBody(props: {
    readonly post: unknown;
}): React.ReactNode
PostBody
post: unknownpost={
const post: {
    title: string;
}
post
} />
<const Suspense: React.ExoticComponent<React.SuspenseProps>

Lets you display a fallback until its children have finished loading.

Suspense
React.SuspenseProps.fallback?: React.ReactNode

A fallback react tree to show when a Suspense child (like React.lazy) suspends

fallback
={<function CommentsSkeleton(): React.ReactNodeCommentsSkeleton />}>
<
function Comments(props: {
    readonly slug: string;
}): React.ReactNode
Comments
slug: stringslug={
props: {
    slug: string;
}
props
.slug: stringslug} />
</const Suspense: React.ExoticComponent<React.SuspenseProps>

Lets you display a fallback until its children have finished loading.

Suspense
>
</> ); }

Suspense makes this worse, not better: a suspending component renders nothing below it, so its children can't even begin to fetch.

raf-query has no fetch-on-render hook. The loader starts every query, and useQuery reads what it started:

src/routes/posts.$slug.tsx
export const 
const Route: Route<Register, Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
    sort: ZodDefault<ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 15 more ..., undefined>
Route
=
createFileRoute<"/posts/$slug", Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
    sort: ZodDefault<ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, "/posts/$slug", "/$slug", "/posts/$slug">(path?: "/posts/$slug" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<...> & ... 1 more ... & UpdatableRouteOptions<...>) | undefined) => Route<...>

Creates a file-based Route factory for a given path.

@parampath — File path literal for the route (usually auto-generated).@returnsA function that accepts Route options and returns a Route instance.
createFileRoute
("/posts/$slug")({
FilebaseRouteOptionsInterface<Register, Route<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 15 more ..., undefined>, ... 12 more ..., undefined>.loader?: Constrain<(args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
    sort: ZodDefault<ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, ... 8 more ..., undefined>, ... 7 more ..., undefined>) => Promise<...>, RouteLoaderFn<...> | RouteLoaderObject<...>> | undefined
loader
: async (
args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
    sort: ZodDefault<ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>
args
) => {
const { const slug: stringslug } =
args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
    sort: ZodDefault<ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>
args
.
LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 15 more ..., undefined>, ... 7 more ..., undefined>.params: {
    slug: string;
}
params
;
const [
const handles: {
    readonly post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>;
    readonly comments: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>;
}
handles
] = await
load<{
    readonly post: LoadEntry<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
    readonly comments: LoadEntry<(input: {
        slug: string;
    }) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">;
}>(client: QueryClient, entries: {
    readonly post: LoadEntry<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
    readonly comments: LoadEntry<(input: {
        slug: string;
    }) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">;
}): Promise<...>

Start a loader's queries and return a serializable handle for each. It starts every entry before it waits for any, so the order of the entries can't create a waterfall. Resolves with [handles, data] once every swr and revalidate entry has data, and rejects with the error as soon as one fails. prefetch entries never make it reject. A skipToken in place of an entry starts nothing and gets no handle.

load
(
args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
    sort: ZodDefault<ZodEnum<{
        newest: "newest";
        oldest: "oldest";
    }>>;
    page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>
args
.
LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 15 more ..., undefined>, ... 7 more ..., undefined>.context: {
    queryClient: QueryClient;
}
context
.queryClient: QueryClient<any>queryClient, {
// Waits only if the post isn't in the cache yet.
post: LoadEntry<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">
post
:
swr<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>(factory: (input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, ...args: LoadEntryArgs<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>): LoadEntry<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">

Use cached data even when it's stale, and wait only when the cache has none. Stale data resolves at once and refetches in the background, as HTTP's stale-while-revalidate does. The load rejects if the fetch it waits for fails.

swr
(
function postQuery(input: {
    slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
postQuery
, { slug: stringslug }),
// Never waits: the comments load while the page renders.
comments: LoadEntry<(input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">
comments
:
prefetch<(input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>(factory: (input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, ...args: LoadEntryArgs<(input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>): LoadEntry<...>

Start the query if the cache has no fresh data, and don't wait for it. It never makes the load reject: errors show up where a component reads the handle.

prefetch
(
function commentsQuery(input: {
    slug: string;
}): InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>
commentsQuery
, { slug: stringslug }),
}); return
const handles: {
    readonly post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>;
    readonly comments: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>;
}
handles
;
}, UpdatableRouteOptionsExtensions.component?: RouteComponent | undefinedcomponent: function PostPage(): React.JSX.ElementPostPage, });

Both requests start the moment the navigation does, before any component renders. With defaultPreload: "intent", they start when the user hovers the link.

Start every query before awaiting any

Inside a loader, put every query in one load. It starts every entry before it waits for any, so their order doesn't matter. A second await load(…) after a first turns two parallel requests back into a waterfall:

{
	// ❌ the comments wait for the post
	const [
const post: {
    readonly post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>;
}
post
] = await
load<{
    readonly post: LoadEntry<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
}>(client: QueryClient, entries: {
    readonly post: LoadEntry<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
}): Promise<Loaded<{
    readonly post: LoadEntry<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
}>>

Start a loader's queries and return a serializable handle for each. It starts every entry before it waits for any, so the order of the entries can't create a waterfall. Resolves with [handles, data] once every swr and revalidate entry has data, and rejects with the error as soon as one fails. prefetch entries never make it reject. A skipToken in place of an entry starts nothing and gets no handle.

load
(
const qc: QueryClient<{
    readonly meta: Flat<unknown>;
}>
qc
, {
post: LoadEntry<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">
post
:
swr<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>(factory: (input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, ...args: LoadEntryArgs<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>): LoadEntry<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">

Use cached data even when it's stale, and wait only when the cache has none. Stale data resolves at once and refetches in the background, as HTTP's stale-while-revalidate does. The load rejects if the fetch it waits for fails.

swr
(
function postQuery(input: {
    slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
postQuery
, { slug: stringslug }) });
const [
const comments: {
    readonly comments: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>;
}
comments
] = await
load<{
    readonly comments: LoadEntry<(input: {
        slug: string;
    }) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">;
}>(client: QueryClient, entries: {
    readonly comments: LoadEntry<(input: {
        slug: string;
    }) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">;
}): Promise<...>

Start a loader's queries and return a serializable handle for each. It starts every entry before it waits for any, so the order of the entries can't create a waterfall. Resolves with [handles, data] once every swr and revalidate entry has data, and rejects with the error as soon as one fails. prefetch entries never make it reject. A skipToken in place of an entry starts nothing and gets no handle.

load
(
const qc: QueryClient<{
    readonly meta: Flat<unknown>;
}>
qc
, {
comments: LoadEntry<(input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">
comments
:
prefetch<(input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>(factory: (input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, ...args: LoadEntryArgs<(input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>): LoadEntry<...>

Start the query if the cache has no fresh data, and don't wait for it. It never makes the load reject: errors show up where a component reads the handle.

prefetch
(
function commentsQuery(input: {
    slug: string;
}): InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>
commentsQuery
, { slug: stringslug }),
}); } { // ✅ both start together const [
const handles: {
    readonly comments: QueryHandle<(input: {
        slug: string;
    }) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>;
    readonly post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>;
}
handles
] = await
load<{
    readonly comments: LoadEntry<(input: {
        slug: string;
    }) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">;
    readonly post: LoadEntry<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
}>(client: QueryClient, entries: {
    readonly comments: LoadEntry<(input: {
        slug: string;
    }) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">;
    readonly post: LoadEntry<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
}): Promise<...>

Start a loader's queries and return a serializable handle for each. It starts every entry before it waits for any, so the order of the entries can't create a waterfall. Resolves with [handles, data] once every swr and revalidate entry has data, and rejects with the error as soon as one fails. prefetch entries never make it reject. A skipToken in place of an entry starts nothing and gets no handle.

load
(
const qc: QueryClient<{
    readonly meta: Flat<unknown>;
}>
qc
, {
comments: LoadEntry<(input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">
comments
:
prefetch<(input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>(factory: (input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, ...args: LoadEntryArgs<(input: {
    slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>): LoadEntry<...>

Start the query if the cache has no fresh data, and don't wait for it. It never makes the load reject: errors show up where a component reads the handle.

prefetch
(
function commentsQuery(input: {
    slug: string;
}): InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>
commentsQuery
, { slug: stringslug }),
post: LoadEntry<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">
post
:
swr<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>(factory: (input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, ...args: LoadEntryArgs<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>): LoadEntry<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">

Use cached data even when it's stale, and wait only when the cache has none. Stale data resolves at once and refetches in the background, as HTTP's stale-while-revalidate does. The load rejects if the fetch it waits for fails.

swr
(
function postQuery(input: {
    slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
postQuery
, { slug: stringslug }),
}); }

Several queries the route can't render without are swr entries in the same load, which waits for them together.

Dependent queries

A query whose input comes from another query's result is a waterfall by construction. A post page that shows "more by this author" needs authorQuery({ id: post.authorId }), and authorId is only known once the post has loaded, so the author's request can't start before the post's has finished. Fix it where the data is shaped, so every query a route needs can start from what the URL already holds:

  • Put the input in the route. If the page is about an organization, its id belongs in the path (/orgs/$orgId), not in a lookup on the current user.
  • Have the backend resolve it. The server already knows the session and the relations, so an endpoint can return the child with its parent (the post with its author), or be keyed by what the client has (GET /posts/:slug/author instead of the post then GET /authors/:id, or GET /me/org instead of GET /me then GET /orgs/:id). The demo above does this: its loader starts the author from the post's id.
Example: the current user's organization

A settings page needs the user and their organization, and the organization's id is a field of the user:

export const 
const Route: Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", "/settings", "/settings", "/settings", undefined, ResolveParams<"/settings">, AnyContext, AnyContext, AnyContext, {}, (args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>) => Promise<...>, ... 4 more ..., undefined>
Route
=
createFileRoute<"/settings", RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", "/settings", "/settings">(path?: "/settings" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<...> & ... 1 more ... & UpdatableRouteOptions<...>) | undefined) => Route<...>

Creates a file-based Route factory for a given path.

@parampath — File path literal for the route (usually auto-generated).@returnsA function that accepts Route options and returns a Route instance.
createFileRoute
("/settings")({
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;
}, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>) => Promise<...>, RouteLoaderFn<...> | RouteLoaderObject<...>> | undefined
loader
: async (
args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", ResolveParams<"/settings">, {}, AnyContext, AnyContext, AnyContext, unknown, undefined>
args
) => {
const const qc: QueryClient<any>qc =
args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", ResolveParams<"/settings">, {}, 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<any>queryClient;
const [
const userHandles: {
    readonly user: QueryHandle<() => QueryOptions<readonly ["user", "current"], {
        orgId: string;
    }, {
        orgId: string;
    }, DefaultTypes>>;
}
userHandles
,
const data: LoadedData<{
    readonly user: LoadEntry<() => QueryOptions<readonly ["user", "current"], {
        orgId: string;
    }, {
        orgId: string;
    }, DefaultTypes>, "revalidate">;
}>
data
] = await
load<{
    readonly user: LoadEntry<() => QueryOptions<readonly ["user", "current"], {
        orgId: string;
    }, {
        orgId: string;
    }, DefaultTypes>, "revalidate">;
}>(client: QueryClient, entries: {
    readonly user: LoadEntry<() => QueryOptions<readonly ["user", "current"], {
        orgId: string;
    }, {
        orgId: string;
    }, DefaultTypes>, "revalidate">;
}): Promise<Loaded<{
    readonly user: LoadEntry<() => QueryOptions<readonly ["user", "current"], {
        orgId: string;
    }, {
        orgId: string;
    }, DefaultTypes>, "revalidate">;
}>>

Start a loader's queries and return a serializable handle for each. It starts every entry before it waits for any, so the order of the entries can't create a waterfall. Resolves with [handles, data] once every swr and revalidate entry has data, and rejects with the error as soon as one fails. prefetch entries never make it reject. A skipToken in place of an entry starts nothing and gets no handle.

load
(const qc: QueryClient<any>qc, {
user: LoadEntry<() => QueryOptions<readonly ["user", "current"], {
    orgId: string;
}, {
    orgId: string;
}, DefaultTypes>, "revalidate">
user
:
revalidate<() => QueryOptions<readonly ["user", "current"], {
    orgId: string;
}, {
    orgId: string;
}, DefaultTypes>>(factory: () => QueryOptions<readonly ["user", "current"], {
    orgId: string;
}, {
    orgId: string;
}, DefaultTypes>, ...args: LoadEntryArgs<() => QueryOptions<readonly ["user", "current"], {
    orgId: string;
}, {
    orgId: string;
}, DefaultTypes>>): LoadEntry<() => QueryOptions<readonly ["user", "current"], {
    orgId: string;
}, {
    orgId: string;
}, DefaultTypes>, "revalidate">

Wait for fresh data, as HTTP's must-revalidate does. Fresh cached data resolves at once without a refetch (staleTime is the max-age); with stale data or none it waits for the fetch. On a server client, data counts as fresh until a write invalidates it. The load rejects if the fetch fails.

revalidate
(
function currentUserQuery(): QueryOptions<readonly ["user", "current"], {
    orgId: string;
}, {
    orgId: string;
}, DefaultTypes>
currentUserQuery
),
}); const [
const orgHandles: {
    readonly org: QueryHandle<(input: {
        orgId: string;
    }) => QueryOptions<readonly ["org", string], {
        id: string;
    }, {
        id: string;
    }, DefaultTypes>>;
}
orgHandles
] = await
load<{
    readonly org: LoadEntry<(input: {
        orgId: string;
    }) => QueryOptions<readonly ["org", string], {
        id: string;
    }, {
        id: string;
    }, DefaultTypes>, "prefetch">;
}>(client: QueryClient, entries: {
    readonly org: LoadEntry<(input: {
        orgId: string;
    }) => QueryOptions<readonly ["org", string], {
        id: string;
    }, {
        id: string;
    }, DefaultTypes>, "prefetch">;
}): Promise<Loaded<{
    readonly org: LoadEntry<(input: {
        orgId: string;
    }) => QueryOptions<readonly ["org", string], {
        id: string;
    }, {
        id: string;
    }, DefaultTypes>, "prefetch">;
}>>

Start a loader's queries and return a serializable handle for each. It starts every entry before it waits for any, so the order of the entries can't create a waterfall. Resolves with [handles, data] once every swr and revalidate entry has data, and rejects with the error as soon as one fails. prefetch entries never make it reject. A skipToken in place of an entry starts nothing and gets no handle.

load
(const qc: QueryClient<any>qc, {
org: LoadEntry<(input: {
    orgId: string;
}) => QueryOptions<readonly ["org", string], {
    id: string;
}, {
    id: string;
}, DefaultTypes>, "prefetch">
org
:
prefetch<(input: {
    orgId: string;
}) => QueryOptions<readonly ["org", string], {
    id: string;
}, {
    id: string;
}, DefaultTypes>>(factory: (input: {
    orgId: string;
}) => QueryOptions<readonly ["org", string], {
    id: string;
}, {
    id: string;
}, DefaultTypes>, ...args: LoadEntryArgs<(input: {
    orgId: string;
}) => QueryOptions<readonly ["org", string], {
    id: string;
}, {
    id: string;
}, DefaultTypes>>): LoadEntry<(input: {
    orgId: string;
}) => QueryOptions<readonly ["org", string], {
    id: string;
}, {
    id: string;
}, DefaultTypes>, "prefetch">

Start the query if the cache has no fresh data, and don't wait for it. It never makes the load reject: errors show up where a component reads the handle.

prefetch
(
function orgQuery(input: {
    orgId: string;
}): QueryOptions<readonly ["org", string], {
    id: string;
}, {
    id: string;
}, DefaultTypes>
orgQuery
, { orgId: stringorgId:
const data: LoadedData<{
    readonly user: LoadEntry<() => QueryOptions<readonly ["user", "current"], {
        orgId: string;
    }, {
        orgId: string;
    }, DefaultTypes>, "revalidate">;
}>
data
.
user: {
    orgId: string;
}
user
.orgId: stringorgId }),
}); return { ...
const userHandles: {
    readonly user: QueryHandle<() => QueryOptions<readonly ["user", "current"], {
        orgId: string;
    }, {
        orgId: string;
    }, DefaultTypes>>;
}
userHandles
, ...
const orgHandles: {
    readonly org: QueryHandle<(input: {
        orgId: string;
    }) => QueryOptions<readonly ["org", string], {
        id: string;
    }, {
        id: string;
    }, DefaultTypes>>;
}
orgHandles
};
}, });

With a 150 ms round trip, the organization can't arrive before 300 ms, and the navigation is blocked for the first 150 even though nothing on the page needed the user that early.

Fix it in the backend: the server can find the session's organization itself, so give it an endpoint and a query with no input.

export function function currentOrgQuery(): QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>currentOrgQuery() {
	return 
queryOptions<readonly ["org", "current"], Org, Org>(options: BaseOptions<readonly ["org", "current"], DefaultTypes> & {
    readonly queryFn: (context: QueryFunctionContext<readonly ["org", "current"]>) => Awaitable<Org>;
    readonly select?: ((data: Org) => Org) | undefined;
}): QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes> (+1 overload)

Typed options for a query factory:

queryOptions
({
BaseOptions<readonly ["org", "current"], DefaultTypes>.queryKey: readonly ["org", "current"]

A plain array, usually built by a group(). Handles keep its literal type.

queryKey
:
const org: Group
<readonly ["current"]>(rest_0: "current") => readonly ["org", "current"]
org
("current"),
queryFn: (context: QueryFunctionContext<readonly ["org", "current"]>) => Awaitable<Org>queryFn: () => function getCurrentOrg(): Promise<Org>getCurrentOrg(), }); } export const
const Route: Route<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", "/settings", "/settings", "/settings", undefined, ResolveParams<"/settings">, AnyContext, AnyContext, AnyContext, {}, (args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>) => Promise<...>, ... 4 more ..., undefined>
Route
=
createFileRoute<"/settings", RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", "/settings", "/settings">(path?: "/settings" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<...> & ... 1 more ... & UpdatableRouteOptions<...>) | undefined) => Route<...>

Creates a file-based Route factory for a given path.

@parampath — File path literal for the route (usually auto-generated).@returnsA function that accepts Route options and returns a Route instance.
createFileRoute
("/settings")({
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;
}, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>) => Promise<...>, RouteLoaderFn<...> | RouteLoaderObject<...>> | undefined
loader
: async (
args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", ResolveParams<"/settings">, {}, AnyContext, AnyContext, AnyContext, unknown, undefined>
args
) => {
const [
const handles: {
    readonly org: QueryHandle<() => QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>>;
    readonly user: QueryHandle<() => QueryOptions<readonly ["user", "current"], {
        orgId: string;
    }, {
        orgId: string;
    }, DefaultTypes>>;
}
handles
] = await
load<{
    readonly org: LoadEntry<() => QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>, "prefetch">;
    readonly user: LoadEntry<() => QueryOptions<readonly ["user", "current"], {
        orgId: string;
    }, {
        orgId: string;
    }, DefaultTypes>, "prefetch">;
}>(client: QueryClient, entries: {
    readonly org: LoadEntry<() => QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>, "prefetch">;
    readonly user: LoadEntry<() => QueryOptions<readonly ["user", "current"], {
        orgId: string;
    }, {
        orgId: string;
    }, DefaultTypes>, "prefetch">;
}): Promise<...>

Start a loader's queries and return a serializable handle for each. It starts every entry before it waits for any, so the order of the entries can't create a waterfall. Resolves with [handles, data] once every swr and revalidate entry has data, and rejects with the error as soon as one fails. prefetch entries never make it reject. A skipToken in place of an entry starts nothing and gets no handle.

load
(
args: LoaderFnContext<Register, RootRoute<Register, undefined, {
    queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", ResolveParams<"/settings">, {}, 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<any>queryClient, {
org: LoadEntry<() => QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>, "prefetch">org: prefetch<() => QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>>(factory: () => QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>, ...args: LoadEntryArgs<() => QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>>): LoadEntry<() => QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>, "prefetch">

Start the query if the cache has no fresh data, and don't wait for it. It never makes the load reject: errors show up where a component reads the handle.

prefetch
(function currentOrgQuery(): QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>currentOrgQuery),
user: LoadEntry<() => QueryOptions<readonly ["user", "current"], {
    orgId: string;
}, {
    orgId: string;
}, DefaultTypes>, "prefetch">
user
:
prefetch<() => QueryOptions<readonly ["user", "current"], {
    orgId: string;
}, {
    orgId: string;
}, DefaultTypes>>(factory: () => QueryOptions<readonly ["user", "current"], {
    orgId: string;
}, {
    orgId: string;
}, DefaultTypes>, ...args: LoadEntryArgs<() => QueryOptions<readonly ["user", "current"], {
    orgId: string;
}, {
    orgId: string;
}, DefaultTypes>>): LoadEntry<() => QueryOptions<readonly ["user", "current"], {
    orgId: string;
}, {
    orgId: string;
}, DefaultTypes>, "prefetch">

Start the query if the cache has no fresh data, and don't wait for it. It never makes the load reject: errors show up where a component reads the handle.

prefetch
(
function currentUserQuery(): QueryOptions<readonly ["user", "current"], {
    orgId: string;
}, {
    orgId: string;
}, DefaultTypes>
currentUserQuery
),
}); return
const handles: {
    readonly org: QueryHandle<() => QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>>;
    readonly user: QueryHandle<() => QueryOptions<readonly ["user", "current"], {
        orgId: string;
    }, {
        orgId: string;
    }, DefaultTypes>>;
}
handles
;
}, });

Or fix it in the routes: links into the page come from places that already know the organization (the org switcher, a list of orgs), so they can put its id in the URL.

Either way, both requests start together, and the page is ready after one round trip instead of two.

When the data really can't be reshaped (a third-party API you don't control), you have two options, and both pay the extra round trip:

  • In the loader: load the post with revalidate, then build the author's input from load's data. The navigation waits for both.
  • In a component: read the author with a waterfallHandle, below. The rest of the page renders first.

Render-time inputs: waterfallHandle

When an input is only known at render time (the post's authorId), no loader can start the query. waterfallHandle builds its handle from the factory and the input, so the consumer looks the same as one fed by a loader. It starts nothing: the component that reads the handle fetches while it renders.

function 
function PostAuthor(props: {
    post: Post;
}): React.JSX.Element
PostAuthor
(
props: {
    post: Post;
}
props
: { post: Postpost: Post }) {
const
const handle: QueryHandle<(input: {
    id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>
handle
=
waterfallHandle<(input: {
    id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>(factory: (input: {
    id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>, input: {
    id: string;
}): QueryHandle<(input: {
    id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>> (+1 overload)

The handle of a query whose input is only known while rendering, like an id in another query's data. It starts nothing: the component that reads it fetches when it renders, which makes it a request waterfall, and the name keeps that visible. Prefer starting queries in a loader with load.

waterfallHandle
(
function authorQuery(input: {
    id: string;
}): QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>
authorQuery
, { id: stringid:
props: {
    post: Post;
}
props
.post: Postpost.Post.authorId: stringauthorId });
const [
const authorThenable: QueryValue<(input: {
    id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>
authorThenable
] =
useQuery<(input: {
    id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>(factory: (input: {
    id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>, handle: QueryHandle<(input: {
    id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>, options?: UseQueryOptions): readonly [...]

Read a query by its handle.

useQuery
(
function authorQuery(input: {
    id: string;
}): QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>
authorQuery
,
const handle: QueryHandle<(input: {
    id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>
handle
);
const const author: Authorauthor = use<Author>(usable: React.Usable<Author>): Authoruse(
const authorThenable: QueryValue<(input: {
    id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>
authorThenable
);
return <
function AuthorCard(props: {
    readonly author: unknown;
}): React.ReactNode
AuthorCard
author: unknownauthor={const author: Authorauthor} />;
}

The reader fetches only when the cache has no data for the query. Cached data, even stale, renders as is, and refetches the way any read query does: when it's invalidated, on window focus, and so on.

The name is the warning: each waterfall is an extra round trip after render. Search a codebase for waterfallHandle and you have a list of every waterfall in it. In development, useQuery warns in the console when it fetches a query that nothing loaded and that didn't come from waterfallHandle, so accidental waterfalls show up too.

On the server, the waterfall happens there or moves to the browser. In TanStack Start, the reader fetches during the server render and the router streams the result. In React Router, it fetches during the server render, and the browser fetches it again. Under server components, the reader's Suspense boundary renders its fallback on the server, and the query loads in the browser (see SSR and hydration). To fetch it once, on the server, start the dependent query in the loader or server component instead:

const [
const postHandles: {
    readonly post: QueryHandle<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>;
}
postHandles
,
const data: LoadedData<{
    readonly post: LoadEntry<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "revalidate">;
}>
data
] = await
load<{
    readonly post: LoadEntry<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "revalidate">;
}>(client: QueryClient, entries: {
    readonly post: LoadEntry<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "revalidate">;
}): Promise<Loaded<{
    readonly post: LoadEntry<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "revalidate">;
}>>

Start a loader's queries and return a serializable handle for each. It starts every entry before it waits for any, so the order of the entries can't create a waterfall. Resolves with [handles, data] once every swr and revalidate entry has data, and rejects with the error as soon as one fails. prefetch entries never make it reject. A skipToken in place of an entry starts nothing and gets no handle.

load
(
const qc: QueryClient<{
    readonly meta: Flat<unknown>;
}>
qc
, {
post: LoadEntry<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "revalidate">
post
:
revalidate<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>(factory: (input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, ...args: LoadEntryArgs<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>): LoadEntry<(input: {
    slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "revalidate">

Wait for fresh data, as HTTP's must-revalidate does. Fresh cached data resolves at once without a refetch (staleTime is the max-age); with stale data or none it waits for the fetch. On a server client, data counts as fresh until a write invalidates it. The load rejects if the fetch fails.

revalidate
(
function postQuery(input: {
    slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
postQuery
, { slug: stringslug }),
}); const [
const authorHandles: {
    readonly author: QueryHandle<(input: {
        id: string;
    }) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>;
}
authorHandles
] = await
load<{
    readonly author: LoadEntry<(input: {
        id: string;
    }) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>, "prefetch">;
}>(client: QueryClient, entries: {
    readonly author: LoadEntry<(input: {
        id: string;
    }) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>, "prefetch">;
}): Promise<Loaded<{
    readonly author: LoadEntry<(input: {
        id: string;
    }) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>, "prefetch">;
}>>

Start a loader's queries and return a serializable handle for each. It starts every entry before it waits for any, so the order of the entries can't create a waterfall. Resolves with [handles, data] once every swr and revalidate entry has data, and rejects with the error as soon as one fails. prefetch entries never make it reject. A skipToken in place of an entry starts nothing and gets no handle.

load
(
const qc: QueryClient<{
    readonly meta: Flat<unknown>;
}>
qc
, {
author: LoadEntry<(input: {
    id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>, "prefetch">
author
:
prefetch<(input: {
    id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>(factory: (input: {
    id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>, ...args: LoadEntryArgs<(input: {
    id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>): LoadEntry<(input: {
    id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>, "prefetch">

Start the query if the cache has no fresh data, and don't wait for it. It never makes the load reject: errors show up where a component reads the handle.

prefetch
(
function authorQuery(input: {
    id: string;
}): QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>
authorQuery
, { id: stringid:
const data: LoadedData<{
    readonly post: LoadEntry<(input: {
        slug: string;
    }) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "revalidate">;
}>
data
.post: Postpost.Post.authorId: stringauthorId }),
});

Code splitting

Lazy-loaded route components are another source of waterfalls: the code has to download before it can render and discover what to fetch. Loaders avoid this too. A route's loader is part of the route definition, not the lazy component, so the router runs it in parallel with downloading the component's code.

These docs do the same: each page's demos are separate chunks, and the page's loader starts loading them alongside the page's content instead of after it renders.

Summary

  • Start queries in loaders and server components, never in render.
  • Start every query a loader needs in one load.
  • Shape data so a route's inputs come from the URL, not from other queries.
  • Use waterfallHandle only when you can't, and treat each one as a known cost.