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>Suspense React.SuspenseProps.fallback?: React.ReactNodefallback={<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>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. useQuery only reads; the loader starts every query:

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<...>
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 qc: QueryClient<never>qc =
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<never>queryClient;
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
;
// Never waits: the comments load while the page renders. const
const comments: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
comments
= const qc: QueryClient<never>qc.
QueryClient<never>.prefetch<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>(factory: (input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>, input: {
    slug: string;
}): QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
prefetch
(
function commentsQuery(input: {
    slug: string;
}): InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>
commentsQuery
, { slug: stringslug });
// Waits only if the post isn't in the cache yet. const
const post: QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
= await const qc: QueryClient<never>qc.
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
};
}, 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, the order matters. An await before a prefetch turns two parallel requests back into a waterfall:

{
	// ❌ comments wait for the post
	const 
const post: QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
= await const qc: QueryClient<never>qc.
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 });
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 });
} { // ✅ both start together const
const comments: QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
comments
= const qc: QueryClient<never>qc.
QueryClient<never>.prefetch<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>(factory: (input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>, input: {
    slug: string;
}): QueryHandle<(input: {
    slug: string;
}) => InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>>
prefetch
(
function commentsQuery(input: {
    slug: string;
}): InfiniteQueryOptions<Flat<{
    key: readonly ["comments", string];
    page: CommentPage;
    pageParam: number;
}>>
commentsQuery
, { slug: stringslug });
const
const post: QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
= await const qc: QueryClient<never>qc.
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 });
}

For several queries the route can't render without, allKeyed awaits them together:

return await 
allKeyed<{
    author: Promise<QueryHandleWithData<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: {
            name: string;
        };
        key: readonly ["author", string];
    }>>>>;
    post: Promise<QueryHandleWithData<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>>;
}>(record: {
    author: Promise<QueryHandleWithData<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: {
            name: string;
        };
        key: readonly ["author", string];
    }>>>>;
    post: Promise<QueryHandleWithData<(input: {
        slug: string;
    }) => QueryOptions<Flat<{
        data: Post;
        key: readonly ["post", string];
    }>>>>;
}): Promise<...>
allKeyed
({
author: Promise<QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: {
        name: string;
    };
    key: readonly ["author", string];
}>>>>
author
: const qc: QueryClient<never>qc.
QueryClient<never>.ensure<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: {
        name: string;
    };
    key: readonly ["author", string];
}>>>(factory: (input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: {
        name: string;
    };
    key: readonly ["author", string];
}>>, input: {
    slug: string;
}): Promise<QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: {
        name: string;
    };
    key: readonly ["author", string];
}>>>> (+1 overload)
ensure
(
function authorQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: {
        name: string;
    };
    key: readonly ["author", string];
}>>
authorQuery
, { slug: stringslug }),
post: Promise<QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>>
post
: const qc: QueryClient<never>qc.
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 }),
});

Dependent queries

A query whose input comes from another query's result is a waterfall by construction. 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, or be keyed by what the client has (GET /me/org instead of GET /me then GET /orgs/: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<...>
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<never>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<never>queryClient;
const
const user: QueryHandleWithData<() => QueryOptions<Flat<{
    data: {
        orgId: string;
    };
    key: readonly ["user", "current"];
}>>>
user
= await const qc: QueryClient<never>qc.
QueryClient<never>.fetch<() => QueryOptions<Flat<{
    data: {
        orgId: string;
    };
    key: readonly ["user", "current"];
}>>>(factory: () => QueryOptions<Flat<{
    data: {
        orgId: string;
    };
    key: readonly ["user", "current"];
}>>, input?: undefined): Promise<QueryHandleWithData<() => QueryOptions<Flat<{
    data: {
        orgId: string;
    };
    key: readonly ["user", "current"];
}>>>> (+1 overload)
fetch
(
function currentUserQuery(): QueryOptions<Flat<{
    data: {
        orgId: string;
    };
    key: readonly ["user", "current"];
}>>
currentUserQuery
);
return {
org: QueryHandle<(input: {
    orgId: string;
}) => QueryOptions<Flat<{
    data: {
        id: string;
    };
    key: readonly ["org", string];
}>>>
org
: const qc: QueryClient<never>qc.
QueryClient<never>.prefetch<(input: {
    orgId: string;
}) => QueryOptions<Flat<{
    data: {
        id: string;
    };
    key: readonly ["org", string];
}>>>(factory: (input: {
    orgId: string;
}) => QueryOptions<Flat<{
    data: {
        id: string;
    };
    key: readonly ["org", string];
}>>, input: {
    orgId: string;
}): QueryHandle<(input: {
    orgId: string;
}) => QueryOptions<Flat<{
    data: {
        id: string;
    };
    key: readonly ["org", string];
}>>>
prefetch
(
function orgQuery(input: {
    orgId: string;
}): QueryOptions<Flat<{
    data: {
        id: string;
    };
    key: readonly ["org", string];
}>>
orgQuery
, { orgId: stringorgId:
const user: QueryHandleWithData<() => QueryOptions<Flat<{
    data: {
        orgId: string;
    };
    key: readonly ["user", "current"];
}>>>
user
.
QueryHandleWithData<() => QueryOptions<Flat<{ data: { orgId: string; }; key: readonly ["user", "current"]; }>>>.data: {
    orgId: string;
}
data
.orgId: stringorgId }),
user: QueryHandleWithData<() => QueryOptions<Flat<{
    data: {
        orgId: string;
    };
    key: readonly ["user", "current"];
}>>>
user
,
}; }, });

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<Flat<{
    data: Org;
    key: readonly ["org", "current"];
}>>
currentOrgQuery
() {
return
queryOptions<Org, unknown, never, readonly ["org", "current"], undefined>(options: Omit<QueryOptions<{
    data: Org;
    error: unknown;
    key: readonly ["org", "current"];
    services: never;
}>, "kind" | "throwOnError"> & ThrowOnErrorField<undefined>): QueryOptions<Flat<{
    data: Org;
    key: readonly ["org", "current"];
}>>
queryOptions
({
queryKey: readonly ["org", "current"]queryKey: ["org", "current"],
function queryFn(context: QueryFunctionContext): QueryFnResult<{
    data: Org;
    error: unknown;
    key: readonly ["org", "current"];
    services: never;
}>
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>) => {
    ...;
}, ... 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<...>
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>) => {
    ...;
}, RouteLoaderFn<...> | RouteLoaderObject<...>> | undefined
loader
: (
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<never>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<never>queryClient;
return {
org: QueryHandle<() => QueryOptions<Flat<{
    data: Org;
    key: readonly ["org", "current"];
}>>>
org
: const qc: QueryClient<never>qc.
QueryClient<never>.prefetch<() => QueryOptions<Flat<{
    data: Org;
    key: readonly ["org", "current"];
}>>>(factory: () => QueryOptions<Flat<{
    data: Org;
    key: readonly ["org", "current"];
}>>, input?: undefined): QueryHandle<() => QueryOptions<Flat<{
    data: Org;
    key: readonly ["org", "current"];
}>>>
prefetch
(
function currentOrgQuery(): QueryOptions<Flat<{
    data: Org;
    key: readonly ["org", "current"];
}>>
currentOrgQuery
),
user: QueryHandle<() => QueryOptions<Flat<{
    data: {
        orgId: string;
    };
    key: readonly ["user", "current"];
}>>>
user
: const qc: QueryClient<never>qc.
QueryClient<never>.prefetch<() => QueryOptions<Flat<{
    data: {
        orgId: string;
    };
    key: readonly ["user", "current"];
}>>>(factory: () => QueryOptions<Flat<{
    data: {
        orgId: string;
    };
    key: readonly ["user", "current"];
}>>, input?: undefined): QueryHandle<() => QueryOptions<Flat<{
    data: {
        orgId: string;
    };
    key: readonly ["user", "current"];
}>>>
prefetch
(
function currentUserQuery(): QueryOptions<Flat<{
    data: {
        orgId: string;
    };
    key: readonly ["user", "current"];
}>>
currentUserQuery
),
}; }, });

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: await qc.fetch(...) the parent and build the input from its data. The navigation waits for both.
  • In a component: start the child with useWaterfall, below. The rest of the page renders first.

Render-time inputs: useWaterfall

When an input is only known at render time (an id from a parent query's result), no loader can start the query. useWaterfall starts it during render and returns a handle, so the consumer looks the same as one fed by a loader:

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<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>>
handle
=
useWaterfall<(input: {
    id: string;
}) => QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>>(factory: (input: {
    id: string;
}) => QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>, input: {
    id: string;
}): QueryHandle<(input: {
    id: string;
}) => QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>>
useWaterfall
(
function authorQuery(input: {
    id: string;
}): QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>
authorQuery
, { id: stringid:
props: {
    post: Post;
}
props
.post: Postpost.Post.authorId: stringauthorId });
const [const authorPromise: Thenable<Author>authorPromise] =
useQuery<(input: {
    id: string;
}) => QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>, undefined>(factory: (input: {
    id: string;
}) => QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>, handle: QueryHandle<(input: {
    id: string;
}) => QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>>, options?: (Options & {
    readonly throwOnError?: never;
} & {
    readonly throwOnError?: undefined;
}) | undefined): readonly [promise: Thenable<Author>, controls: QueryControls<...>] (+1 overload)
useQuery
(
function authorQuery(input: {
    id: string;
}): QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>
authorQuery
,
const handle: QueryHandle<(input: {
    id: string;
}) => QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>>
handle
);
const const author: Authorauthor = use<Author>(usable: React.Usable<Author>): Authoruse(const authorPromise: Thenable<Author>authorPromise); return <
function AuthorCard(props: {
    readonly author: unknown;
}): React.ReactNode
AuthorCard
author: unknownauthor={const author: Authorauthor} />;
}

Like prefetch in a loader, useWaterfall refetches stale cached data in the background, once each time the component mounts or its input changes. Re-renders don't refetch.

The name is the warning: each waterfall is an extra round trip after render. Search a codebase for useWaterfall and you have a list of every waterfall in it.

In TanStack Start, the queryFn runs on the server during SSR and the router streams the result. Under the Next.js QueryProvider it starts nothing on the server: the reader's Suspense boundary renders its fallback there, and the query loads in the browser. To have it in the HTML, start the dependent query in the server component instead:

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 });
const
const author: QueryHandle<(input: {
    id: string;
}) => QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>>
author
= const qc: QueryClient<never>qc.
QueryClient<never>.prefetch<(input: {
    id: string;
}) => QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>>(factory: (input: {
    id: string;
}) => QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>, input: {
    id: string;
}): QueryHandle<(input: {
    id: string;
}) => QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>>
prefetch
(
function authorQuery(input: {
    id: string;
}): QueryOptions<Flat<{
    data: Author;
    key: readonly ["author", string];
}>>
authorQuery
, { id: stringid:
const post: QueryHandleWithData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>
post
.QueryHandleWithData<(input: { slug: string; }) => QueryOptions<Flat<{ data: Post; key: readonly ["post", string]; }>>>.data: Postdata.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 before awaiting any.
  • Shape data so a route's inputs come from the URL, not from other queries.
  • Use useWaterfall only when you can't, and treat each one as a known cost.