Skip to content
TkDodo's blog

Reliable Query Prefetching with TanStack Router

Reliable Query Prefetching with TanStack Router
Photo by Mario Heller
TanStack Router

In the last blog post of this series, I described all the advantages of the imo recommended pattern to do data fetching in TanStack Router with TanStack Query. To recap, it’s about triggering fetches as early as possible in the loader, but also about treating the loader as an event handler so that components can “pick up” the promise with useSuspenseQuery or useQuery.

The takeaway here should be that a component should, if possible, probably never initiate data fetching on its own. Yes, it’s great in theory that you have a fully self-contained component that has its own data requirements and resolves them automatically when it renders. But in practice, if that’s the only place where we fetch, we are 1) doing this probably way later than we ideally should and 2) we can easily introduce fetch waterfalls (with or without suspense).

So, route loader to the rescue. 🎉 As usual, there is a cost to it though:

The Great Divergence

The big drawback of this approach is that you essentially duplicate what you do in the component in a second location - the route loader. Now duplication is not the end of the world, and it’s a fine trade-off for the UX you are giving your app, but there is one thing that’s really critical to keep in mind as your app grows:

Those two places have to be 100% in sync

And usually, over time, this gets harder to ensure. Minimal examples are great for blogposts, but they don’t necessarily reflect the real world. As we’ve built larger apps with TanStack Router, we’ve hit this “limitation” pretty quickly. There are a couple of things that contributed to this:

  1. Your components usually won’t stay in the Route file.

At least not completely. component: Dashboard is nice, but Dashboard is likely composed of a lot more components, who will also need access to the route APIs. TanStack Router has us covered with the getRouteApi (opens in a new window), so we can easily start to break things up into separate files:

getRouteApi
const
const Route: RouteApi<"/dashboard/$dashboardId", RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {}, AnyContext, AnyContext, {}, undefined, readonly [Route<Register, RootRoute<Register, undefined, {}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", undefined, ResolveParams<"/dashboard/$dashboardId">, ... 9 more ..., undefined>], unknown, unknown, unknown, undefined>, "never", false, RouterHistory, Record<...>>>
Route
=
getRouteApi<"/dashboard/$dashboardId", RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {}, AnyContext, AnyContext, {}, undefined, readonly [Route<Register, RootRoute<Register, undefined, {}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", undefined, ResolveParams<"/dashboard/$dashboardId">, ... 9 more ..., undefined>], unknown, unknown, unknown, undefined>, "never", false, RouterHistory, Record<...>>>(id: ConstrainLiteral<...>): RouteApi<...>
getRouteApi
('/dashboard/$dashboardId')
function
function DashboardContent(): void
DashboardContent
() {
const
const params: {
dashboardId: string;
}
params
=
const Route: RouteApi<"/dashboard/$dashboardId", RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {}, AnyContext, AnyContext, {}, undefined, readonly [Route<Register, RootRoute<Register, undefined, {}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", undefined, ResolveParams<"/dashboard/$dashboardId">, ... 9 more ..., undefined>], unknown, unknown, unknown, undefined>, "never", false, RouterHistory, Record<...>>>
Route
.
RouteApi<"/dashboard/$dashboardId", RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {}, AnyContext, AnyContext, ... 6 more ..., undefined>, "never", false, RouterHistory, Record<...>>>.useParams: <RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {}, AnyContext, AnyContext, {}, undefined, readonly [Route<Register, RootRoute<Register, undefined, {}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", undefined, ResolveParams<"/dashboard/$dashboardId">, AnyContext, ... 8 more ..., undefined>], unknown, unknown, unknown, undefined>, "never", false, RouterHistory, Record<...>>, unknown, boolean>(opts?: (UseParamsBaseOptions<...> & OptionalStructuralSharing<...>) | undefined) => {
...;
}
useParams
()
const {
const data: Dashboard
data
} =
useSuspenseQuery<Dashboard, Error, Dashboard, string[]>(options: UseSuspenseQueryOptions<Dashboard, Error, Dashboard, string[]>, queryClient?: QueryClient): UseSuspenseQueryResult<Dashboard, Error>
useSuspenseQuery
(
const dashboardQueryOptions: (dashboardId: string) => OmitKeyof<UseQueryOptions<Dashboard, Error, Dashboard, string[]>, "queryFn"> & {
queryFn?: QueryFunction<Dashboard, string[], never>;
} & {
queryKey: string[] & {
[dataTagSymbol]: Dashboard;
[dataTagErrorSymbol]: Error;
};
}
dashboardQueryOptions
(
const params: {
dashboardId: string;
}
params
.
dashboardId: string
dashboardId
),
)
}

This is a good thing, but it means when we change how the component uses a Query, we might not immediately think about also adapting this in the route loader.

  1. There is no error if the two aren’t the same

While I have been thinking about a Strict Mode (opens in a new window) to help with prefetching mismatches, we don’t have anything right now, so diverging is really easy to accidentally do.

This means that as your product grows and new features are added, simply changing how you fetch data in the component can cause two issues: you’ll prefetch data you don’t actually need (potentially blocking the route), and you’ll also trigger another fetch for the data you do need, creating a waterfall effect.

Also, if you remove a query from a component but don’t clean-up the route loader, you’ll be unnecessarily prefetching data you don’t need at all.

Example

Suppose we want to add a feature to our Dashboard where we can filter for a date in the past to essentially get a historical snapshot of its data and widgets at that point in time. Since we want this to be shareable, we’ll add an optional ?asOf=YYYY-MM-DD query parameter to our Dashboard route and consume that in the component:

asOf
const
const Route: RouteApi<"/dashboard/$dashboardId", RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {}, AnyContext, AnyContext, {}, undefined, readonly [Route<Register, RootRoute<Register, undefined, {}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", (data: unknown) => {
asOf?: string;
}, ResolveParams<"/dashboard/$dashboardId">, ... 9 more ..., undefined>], unknown, unknown, unknown, undefined>, "never", false, RouterHistory, Record<...>>>
Route
=
getRouteApi<"/dashboard/$dashboardId", RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {}, AnyContext, AnyContext, {}, undefined, readonly [Route<Register, RootRoute<Register, undefined, {}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", (data: unknown) => {
asOf?: string;
}, ResolveParams<"/dashboard/$dashboardId">, ... 9 more ..., undefined>], unknown, unknown, unknown, undefined>, "never", false, RouterHistory, Record<...>>>(id: ConstrainLiteral<...>): RouteApi<...>
getRouteApi
('/dashboard/$dashboardId')
function
function DashboardContent(): void
DashboardContent
() {
const
const params: {
dashboardId: string;
}
params
=
const Route: RouteApi<"/dashboard/$dashboardId", RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {}, AnyContext, AnyContext, {}, undefined, readonly [Route<Register, RootRoute<Register, undefined, {}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", (data: unknown) => {
asOf?: string;
}, ResolveParams<"/dashboard/$dashboardId">, ... 9 more ..., undefined>], unknown, unknown, unknown, undefined>, "never", false, RouterHistory, Record<...>>>
Route
.
RouteApi<"/dashboard/$dashboardId", RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {}, AnyContext, AnyContext, ... 6 more ..., undefined>, "never", false, RouterHistory, Record<...>>>.useParams: <RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {}, AnyContext, AnyContext, {}, undefined, readonly [Route<Register, RootRoute<Register, undefined, {}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", (data: unknown) => {
asOf?: string;
}, ResolveParams<"/dashboard/$dashboardId">, ... 9 more ..., undefined>], unknown, unknown, unknown, undefined>, "never", false, RouterHistory, Record<...>>, unknown, boolean>(opts?: (UseParamsBaseOptions<...> & OptionalStructuralSharing<...>) | undefined) => {
...;
}
useParams
()
const {
const asOf: string | undefined
asOf
} =
const Route: RouteApi<"/dashboard/$dashboardId", RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {}, AnyContext, AnyContext, {}, undefined, readonly [Route<Register, RootRoute<Register, undefined, {}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", (data: unknown) => {
asOf?: string;
}, ResolveParams<"/dashboard/$dashboardId">, ... 9 more ..., undefined>], unknown, unknown, unknown, undefined>, "never", false, RouterHistory, Record<...>>>
Route
.
RouteApi<"/dashboard/$dashboardId", RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {}, AnyContext, AnyContext, ... 6 more ..., undefined>, "never", false, RouterHistory, Record<...>>>.useSearch: <RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, {}, AnyContext, AnyContext, {}, undefined, readonly [Route<Register, RootRoute<Register, undefined, {}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", (data: unknown) => {
asOf?: string;
}, ResolveParams<"/dashboard/$dashboardId">, ... 9 more ..., undefined>], unknown, unknown, unknown, undefined>, "never", false, RouterHistory, Record<...>>, unknown, boolean>(opts?: (UseSearchBaseOptions<...> & OptionalStructuralSharing<...>) | undefined) => {
...;
}
useSearch
()
const {
const data: Dashboard
data
} =
useSuspenseQuery<Dashboard, Error, Dashboard, (string | Options | undefined)[]>(options: UseSuspenseQueryOptions<Dashboard, Error, Dashboard, (string | Options | undefined)[]>, queryClient?: QueryClient): UseSuspenseQueryResult<Dashboard, Error>
useSuspenseQuery
(
const dashboardQueryOptions: (dashboardId: string, options?: Options) => OmitKeyof<UseQueryOptions<Dashboard, Error, Dashboard, (string | Options | undefined)[]>, "queryFn"> & {
queryFn?: QueryFunction<Dashboard, (string | Options | undefined)[], never>;
} & {
queryKey: (string | Options | undefined)[] & {
[dataTagSymbol]: Dashboard;
[dataTagErrorSymbol]: Error;
};
}
dashboardQueryOptions
(
const params: {
dashboardId: string;
}
params
.
dashboardId: string
dashboardId
, {
asOf: string | undefined
asOf
}),
)
}

Now if we load our Dashboard, everything still works fine, and if we change the search param, we’ll also see data from that snapshot. LGTM, ship it. 🚢

I think I already spoiled what the bug is: If we now share a route to our Dashboard, we will:

Only then will we render the component with the accurate data.

This is quite bad because it makes the most common use-case (adding features) hard to get right and the bugs that come from this aren’t easy to spot either. I think we actually hit that issue in the first couple of routes we created, where we were pre-fetching an InfiniteQuery in the route loader that optionally had search, sortBy and sortDirection query params. 😔

It’s a really easy mistake to make, so how do we fix it?

Fix the Route Loader

Well first, we should probably update the route loader to include the optional param there, too. This has to be done with an additional layer of indirection called loaderDeps (opens in a new window).

loaderDeps
export const
const Route: Route<Register, RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", (data: unknown) => {
asOf?: string;
}, ResolveParams<"/dashboard/$dashboardId">, AnyContext, AnyContext, AnyContext, {
asOf: string | undefined;
}, ({ context, params, deps }: LoaderFnContext<Register, ... 8 more ..., undefined>) => Promise<...>, ... 4 more ..., undefined>
Route
=
createFileRoute<"/dashboard/$dashboardId", RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId">(path?: "/dashboard/$dashboardId" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<...> & ... 1 more ... & UpdatableRouteOptions<...>) | undefined) => Route<...>
createFileRoute
('/dashboard/$dashboardId')({
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, ... 12 more ..., undefined>.validateSearch?: Constrain<(data: unknown) => {
asOf?: string;
}, AnyValidator, DefaultValidator>
validateSearch
:
type<{
readonly 'asOf?': "string.date.iso";
}, Type<{
asOf?: string;
}, {}>>(def: validateObjectLiteral<{
readonly 'asOf?': "string.date.iso";
}, {}, bindThis<{
readonly 'asOf?': "string.date.iso";
}>>): Type<{
asOf?: string;
}, {}> (+2 overloads)
type
({ 'asOf?': 'string.date.iso' }).
Inferred<{ asOf?: string; }, {}>.assert: (data: unknown) => {
asOf?: string;
}
assert
,
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, ... 12 more ..., undefined>.loaderDeps?: (opts: FullSearchSchemaOption<RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, (data: unknown) => {
asOf?: string;
}>) => {
asOf: string | undefined;
}
loaderDeps
: ({
FullSearchSchemaOption<RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, (data: unknown) => { ...; }>.search: {
asOf?: string;
}
search
: {
asOf: string | undefined
asOf
} }) => ({
asOf: string | undefined
asOf
}),
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, ... 12 more ..., undefined>.loader?: Constrain<({ context, params, deps }: LoaderFnContext<Register, ... 8 more ..., undefined>) => Promise<...>, RouteLoaderFn<...> | RouteLoaderObject<...>>
loader
: async ({
context: {
queryClient: QueryClient;
}
context
,
params: {
dashboardId: string;
}
params
,
deps: {
asOf: string | undefined;
}
deps
}) => {
await
context: {
queryClient: QueryClient;
}
context
.
queryClient: QueryClient
queryClient
.
QueryClient.ensureQueryData<Dashboard, Error, Dashboard, (string | Options | undefined)[]>(options: EnsureQueryDataOptions<Dashboard, Error, Dashboard, (string | Options | undefined)[], never>): Promise<Dashboard>
ensureQueryData
(
const dashboardQueryOptions: (dashboardId: string, options?: Options) => OmitKeyof<UseQueryOptions<Dashboard, Error, Dashboard, (string | Options | undefined)[]>, "queryFn"> & {
queryFn?: QueryFunction<Dashboard, (string | Options | undefined)[], never>;
} & {
queryKey: (string | Options | undefined)[] & {
[dataTagSymbol]: Dashboard;
[dataTagErrorSymbol]: Error;
};
}
dashboardQueryOptions
(
params: {
dashboardId: string;
}
params
.
dashboardId: string
dashboardId
,
deps: {
asOf: string | undefined;
}
deps
),
)
},
UpdatableRouteOptionsExtensions.component?: RouteComponent
component
:
const Dashboard: () => ReactElement
Dashboard
,
})

This fixes the symptoms (we can now share URLs again without doing multiple unnecessary requests) but it doesn’t address the root cause at all. It’s still likely that we will make the same mistake on the next route again. To make it less likely to happen, we need to fix the duplication.

Fix the Duplication

Let’s compare the two query related calls again - the one in the loader and the one in the component:

queryOptions
//loader
await context.queryClient.ensureQueryData(
dashboardQueryOptions(params.dashboardId, deps),
)
// component
const { data } = useSuspenseQuery(
dashboardQueryOptions(params.dashboardId, { asOf }),
)

We can see that both instances share the same query options, the only difference is the function they’re passed to: ensureQueryData for an imperative, one-time fetch, and useSuspenseQuery for setting up a reactive subscription.

In our case, the bug happened because those two weren’t actually using the same options. If the component always relied on the same queryOptions as the loader, they couldn’t drift apart, so let’s just put the options into Route Context and consume them in both places.

queryOptions in Route Context
export const
const Route: Route<Register, RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", (data: unknown) => {
asOf?: string;
}, ResolveParams<"/dashboard/$dashboardId">, AnyContext, ({ params, deps }: RouteContextOptions<RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, ... 6 more ..., undefined>, ResolveParams<...>, AnyContext, {
...;
}, "/dashboard/$dashboardId">) => {
...;
}, ... 7 more ..., undefined>
Route
=
createFileRoute<"/dashboard/$dashboardId", RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId">(path?: "/dashboard/$dashboardId" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<...> & ... 1 more ... & UpdatableRouteOptions<...>) | undefined) => Route<...>
createFileRoute
('/dashboard/$dashboardId')({
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, ... 12 more ..., undefined>.validateSearch?: Constrain<(data: unknown) => {
asOf?: string;
}, AnyValidator, DefaultValidator>
validateSearch
:
type<{
readonly 'asOf?': "string.date.iso";
}, Type<{
asOf?: string;
}, {}>>(def: validateObjectLiteral<{
readonly 'asOf?': "string.date.iso";
}, {}, bindThis<{
readonly 'asOf?': "string.date.iso";
}>>): Type<{
asOf?: string;
}, {}> (+2 overloads)
type
({ 'asOf?': 'string.date.iso' }).
Inferred<{ asOf?: string; }, {}>.assert: (data: unknown) => {
asOf?: string;
}
assert
,
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, ... 12 more ..., undefined>.loaderDeps?: (opts: FullSearchSchemaOption<RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, (data: unknown) => {
asOf?: string;
}>) => {
asOf: string | undefined;
}
loaderDeps
: ({
FullSearchSchemaOption<RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, (data: unknown) => { ...; }>.search: {
asOf?: string;
}
search
: {
asOf: string | undefined
asOf
} }) => ({
asOf: string | undefined
asOf
}),
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, ... 12 more ..., undefined>.context?: Constrain<({ params, deps }: RouteContextOptions<RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, ... 6 more ..., undefined>, ResolveParams<...>, AnyContext, {
...;
}, "/dashboard/$dashboardId">) => {
...;
}, (ctx: RouteContextOptions<...>) => any>
context
: ({
params: {
dashboardId: string;
}
params
,
deps: {
asOf: string | undefined;
}
deps
}) => ({
dashboardQueryOptions: OmitKeyof<UseQueryOptions<Dashboard, Error, Dashboard, (string | Options | undefined)[]>, "queryFn"> & {
queryFn?: QueryFunction<Dashboard, (string | Options | undefined)[], never>;
} & {
queryKey: (string | Options | undefined)[] & {
[dataTagSymbol]: Dashboard;
[dataTagErrorSymbol]: Error;
};
}
dashboardQueryOptions
:
const dashboardQueryOptions: (dashboardId: string, options?: Options) => OmitKeyof<UseQueryOptions<Dashboard, Error, Dashboard, (string | Options | undefined)[]>, "queryFn"> & {
queryFn?: QueryFunction<Dashboard, (string | Options | undefined)[], never>;
} & {
queryKey: (string | Options | undefined)[] & {
[dataTagSymbol]: Dashboard;
[dataTagErrorSymbol]: Error;
};
}
dashboardQueryOptions
(
params: {
dashboardId: string;
}
params
.
dashboardId: string
dashboardId
,
deps: {
asOf: string | undefined;
}
deps
,
),
}),
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, ... 12 more ..., undefined>.loader?: Constrain<({ context }: LoaderFnContext<Register, RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", ResolveParams<"/dashboard/$dashboardId">, {
asOf: string | undefined;
}, AnyContext, ({ params, deps }: RouteContextOptions<RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, ResolveParams<"/dashboard/$dashboardId">, AnyContext, {
asOf: string | undefined;
}, "/dashboard/$dashboardId">) => {
...;
}, AnyContext, unknown, undefined>) => Promise<...>, RouteLoaderFn<...> | RouteLoaderObject<...>>
loader
: async ({
context: {
queryClient: QueryClient;
dashboardQueryOptions: OmitKeyof<UseQueryOptions<Dashboard, Error, Dashboard, (string | Options | undefined)[]>, "queryFn"> & {
queryFn?: QueryFunction<Dashboard, (string | Options | undefined)[], never>;
} & {
queryKey: (string | Options | undefined)[] & {
[dataTagSymbol]: Dashboard;
[dataTagErrorSymbol]: Error;
};
};
}
context
}) => {
await
context: {
queryClient: QueryClient;
dashboardQueryOptions: OmitKeyof<UseQueryOptions<Dashboard, Error, Dashboard, (string | Options | undefined)[]>, "queryFn"> & {
queryFn?: QueryFunction<Dashboard, (string | Options | undefined)[], never>;
} & {
queryKey: (string | Options | undefined)[] & {
[dataTagSymbol]: Dashboard;
[dataTagErrorSymbol]: Error;
};
};
}
context
.
queryClient: QueryClient
queryClient
.
QueryClient.ensureQueryData<Dashboard, Error, Dashboard, (string | Options | undefined)[]>(options: EnsureQueryDataOptions<Dashboard, Error, Dashboard, (string | Options | undefined)[], never>): Promise<Dashboard>
ensureQueryData
(
context: {
queryClient: QueryClient;
dashboardQueryOptions: OmitKeyof<UseQueryOptions<Dashboard, Error, Dashboard, (string | Options | undefined)[]>, "queryFn"> & {
queryFn?: QueryFunction<Dashboard, (string | Options | undefined)[], never>;
} & {
queryKey: (string | Options | undefined)[] & {
[dataTagSymbol]: Dashboard;
[dataTagErrorSymbol]: Error;
};
};
}
context
.
dashboardQueryOptions: OmitKeyof<UseQueryOptions<Dashboard, Error, Dashboard, (string | Options | undefined)[]>, "queryFn"> & {
queryFn?: QueryFunction<Dashboard, (string | Options | undefined)[], never>;
} & {
queryKey: (string | Options | undefined)[] & {
[dataTagSymbol]: Dashboard;
[dataTagErrorSymbol]: Error;
};
}
dashboardQueryOptions
,
)
},
UpdatableRouteOptionsExtensions.component?: RouteComponent
component
:
const Dashboard: () => null
Dashboard
,
})
// in a different file, far far away
const
const routeApi: RouteApi<"/dashboard/$dashboardId", RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, RouteContext, AnyContext, AnyContext, {}, undefined, readonly [Route<Register, RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", (data: unknown) => {
asOf?: string;
}, ... 10 more ..., undefined>], unknown, unknown, unknown, undefined>, "never", false, RouterHistory, Record<...>>>
routeApi
=
getRouteApi<"/dashboard/$dashboardId", RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, RouteContext, AnyContext, AnyContext, {}, undefined, readonly [Route<Register, RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", (data: unknown) => {
...;
}, ... 10 more ..., undefined>], unknown, unknown, unknown, undefined>, "never", false, RouterHistory, Record<...>>>(id: ConstrainLiteral<...>): RouteApi<...>
getRouteApi
('/dashboard/$dashboardId')
function
function DashboardContent(): void
DashboardContent
() {
const
const context: {
queryClient: QueryClient;
dashboardQueryOptions: OmitKeyof<UseQueryOptions<Dashboard, Error, Dashboard, (string | Options | undefined)[]>, "queryFn"> & {
queryFn?: QueryFunction<Dashboard, (string | Options | undefined)[], never>;
} & {
queryKey: (string | Options | undefined)[] & {
[dataTagSymbol]: Dashboard;
[dataTagErrorSymbol]: Error;
};
};
}
context
=
const routeApi: RouteApi<"/dashboard/$dashboardId", RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, RouteContext, AnyContext, AnyContext, {}, undefined, readonly [Route<Register, RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", (data: unknown) => {
...;
}, ... 10 more ..., undefined>], unknown, unknown, unknown, undefined>, "never", false, RouterHistory, Record<...>>>
routeApi
.
RouteApi<"/dashboard/$dashboardId", RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, RouteContext, AnyContext, ... 7 more ..., undefined>, "never", false, RouterHistory, Record<...>>>.useRouteContext: <RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, RouteContext, AnyContext, AnyContext, {}, undefined, readonly [Route<Register, RootRoute<Register, undefined, RouteContext, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", "/dashboard/$dashboardId", (data: unknown) => {
asOf?: string;
}, ResolveParams<"/dashboard/$dashboardId">, ... 9 more ..., undefined>], unknown, unknown, unknown, undefined>, "never", false, RouterHistory, Record<...>>, unknown>(opts?: UseRouteContextBaseOptions<...> | undefined) => {
...;
}
useRouteContext
()
const {
const data: Dashboard
data
} =
useSuspenseQuery<Dashboard, Error, Dashboard, (string | Options | undefined)[]>(options: UseSuspenseQueryOptions<Dashboard, Error, Dashboard, (string | Options | undefined)[]>, queryClient?: QueryClient): UseSuspenseQueryResult<Dashboard, Error>
useSuspenseQuery
(
const context: {
queryClient: QueryClient;
dashboardQueryOptions: OmitKeyof<UseQueryOptions<Dashboard, Error, Dashboard, (string | Options | undefined)[]>, "queryFn"> & {
queryFn?: QueryFunction<Dashboard, (string | Options | undefined)[], never>;
} & {
queryKey: (string | Options | undefined)[] & {
[dataTagSymbol]: Dashboard;
[dataTagErrorSymbol]: Error;
};
};
}
context
.
dashboardQueryOptions: OmitKeyof<UseQueryOptions<Dashboard, Error, Dashboard, (string | Options | undefined)[]>, "queryFn"> & {
queryFn?: QueryFunction<Dashboard, (string | Options | undefined)[], never>;
} & {
queryKey: (string | Options | undefined)[] & {
[dataTagSymbol]: Dashboard;
[dataTagErrorSymbol]: Error;
};
}
dashboardQueryOptions
)
}

A couple of things happened here. First, we’re no longer depending on params or deps inside the loader or the component. The context function is now the only place where we use those, and its job is to create the queryOptions we want to share and put them under an arbitrary key onto the context.

Then, the loader only uses the context, and the component also only uses the context with Route.useRouteContext().

This little layer of indirection has a lot of advantages. Not only can our de-duplication bug no longer happen, we also know when a component uses a query that has been prefetched. That means we are much more likely to look at the route loader and clean up prefetches if we remove the usage in the component.

And because context inherits from parents, sub-routes will know about those queryOptions, too. We could, for example, prefetch some user data in the root route loader:

Root
export const
const Route: RootRoute<Register, (data: unknown) => {
debug: boolean;
}, RouteContext, () => {
userQueryOptions: OmitKeyof<UseQueryOptions<User, Error, User, string[]>, "queryFn"> & {
queryFn?: QueryFunction<User, string[], never>;
} & {
queryKey: string[] & {
[dataTagSymbol]: User;
[dataTagErrorSymbol]: Error;
};
};
}, AnyContext, {}, ({ context }: LoaderFnContext<Register, any, "__root__", {}, {}, RouteContext, () => {
...;
}, AnyContext, unknown, undefined>) => Promise<...>, ... 4 more ..., undefined>
Route
=
createRootRouteWithContext<RouteContext>(): <TRegister, TRouteContextFn, TBeforeLoadFn, TSearchValidator, TLoaderDeps, TLoaderFn, TSSR, TServerMiddlewares>(options?: RootRouteOptions<TRegister, TSearchValidator, RouteContext, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TSSR, TServerMiddlewares, undefined> | undefined) => RootRoute<...>
createRootRouteWithContext
<
type RouteContext = {
queryClient: QueryClient;
}
RouteContext
>()({
validateSearch?: Constrain<(data: unknown) => {
debug: boolean;
}, AnyValidator, DefaultValidator>
validateSearch
:
type<{
readonly debug: "boolean=false";
}, Type<{
debug: Default<boolean, false>;
}, {}>>(def: validateObjectLiteral<{
readonly debug: "boolean=false";
}, {}, bindThis<{
readonly debug: "boolean=false";
}>>): Type<{
debug: Default<boolean, false>;
}, {}> (+2 overloads)
type
({
debug: "boolean=false"
debug
: `boolean=false` }).
Inferred<{ debug: Default<boolean, false>; }, {}>.assert: (data: unknown) => {
debug: boolean;
}
assert
,
context?: Constrain<() => {
userQueryOptions: OmitKeyof<UseQueryOptions<User, Error, User, string[]>, "queryFn"> & {
queryFn?: QueryFunction<User, string[], never>;
} & {
queryKey: string[] & {
[dataTagSymbol]: User;
[dataTagErrorSymbol]: Error;
};
};
}, (ctx: RouteContextOptions<any, {}, RouteContext, {}, "__root__">) => any>
context
: () => ({
userQueryOptions: OmitKeyof<UseQueryOptions<User, Error, User, string[]>, "queryFn"> & {
queryFn?: QueryFunction<User, string[], never>;
} & {
queryKey: string[] & {
[dataTagSymbol]: User;
[dataTagErrorSymbol]: Error;
};
}
userQueryOptions
:
queryOptions<User, Error, User, string[]>(options: UnusedSkipTokenOptions<User, Error, User, string[]>): OmitKeyof<UseQueryOptions<User, Error, User, string[]>, "queryFn"> & {
queryFn?: QueryFunction<User, string[], never>;
} & {
queryKey: string[] & {
[dataTagSymbol]: User;
[dataTagErrorSymbol]: Error;
};
} (+2 overloads)
queryOptions
({
queryKey: string[]
queryKey
: ['user'],
queryFn?: QueryFunction<User, string[], never>
queryFn
:
const getUser: () => Promise<User>
getUser
,
}),
}),
loader?: Constrain<({ context }: LoaderFnContext<Register, any, "__root__", {}, {}, RouteContext, () => {
...;
}, AnyContext, unknown, undefined>) => Promise<...>, RouteLoaderFn<...> | RouteLoaderObject<...>>
loader
: async ({
context: {
queryClient: QueryClient;
userQueryOptions: OmitKeyof<UseQueryOptions<User, Error, User, string[]>, "queryFn"> & {
queryFn?: QueryFunction<User, string[], never>;
} & {
queryKey: string[] & {
[dataTagSymbol]: User;
[dataTagErrorSymbol]: Error;
};
};
}
context
}) => {
await
context: {
queryClient: QueryClient;
userQueryOptions: OmitKeyof<UseQueryOptions<User, Error, User, string[]>, "queryFn"> & {
queryFn?: QueryFunction<User, string[], never>;
} & {
queryKey: string[] & {
[dataTagSymbol]: User;
[dataTagErrorSymbol]: Error;
};
};
}
context
.
queryClient: QueryClient
queryClient
.
QueryClient.ensureQueryData<User, Error, User, string[]>(options: EnsureQueryDataOptions<User, Error, User, string[], never>): Promise<User>
ensureQueryData
(
context: {
queryClient: QueryClient;
userQueryOptions: OmitKeyof<UseQueryOptions<User, Error, User, string[]>, "queryFn"> & {
queryFn?: QueryFunction<User, string[], never>;
} & {
queryKey: string[] & {
[dataTagSymbol]: User;
[dataTagErrorSymbol]: Error;
};
};
}
context
.
userQueryOptions: OmitKeyof<UseQueryOptions<User, Error, User, string[]>, "queryFn"> & {
queryFn?: QueryFunction<User, string[], never>;
} & {
queryKey: string[] & {
[dataTagSymbol]: User;
[dataTagErrorSymbol]: Error;
};
}
userQueryOptions
,
)
},
component?: RouteComponent
component
:
const Root: () => null
Root
,
})

And then use them too in our Widgets:

Use all queryOptions
function
function Widget(): void
Widget
() {
const {
const userQueryOptions: OmitKeyof<UseQueryOptions<User, Error, User, string[]>, "queryFn"> & {
queryFn?: QueryFunction<User, string[], never>;
} & {
queryKey: string[] & {
[dataTagSymbol]: User;
[dataTagErrorSymbol]: Error;
};
}
userQueryOptions
,
const dashboardQueryOptions: OmitKeyof<UseQueryOptions<Dashboard, Error, Dashboard, string[]>, "queryFn"> & {
queryFn?: QueryFunction<Dashboard, string[], never>;
} & {
queryKey: string[] & {
[dataTagSymbol]: Dashboard;
[dataTagErrorSymbol]: Error;
};
}
dashboardQueryOptions
} =
const Route: Route<Register, Route<Register, RootRoute<Register, undefined, RouteContext, () => {
userQueryOptions: OmitKeyof<UseQueryOptions<User, Error, User, string[]>, "queryFn"> & {
queryFn?: QueryFunction<User, string[], never>;
} & {
queryKey: string[] & {
[dataTagSymbol]: User;
[dataTagErrorSymbol]: Error;
};
};
}, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, ... 15 more ..., undefined>, ... 15 more ..., undefined>
Route
.
RouteExtensions<"/dashboard/$dashboardId/widget/$widgetId/", "/dashboard/$dashboardId/widget/$widgetId/">.useRouteContext: <RouterCore<Route<Register, any, "/", "/", string, "__root__", undefined, {}, RouteContext, () => {
userQueryOptions: OmitKeyof<UseQueryOptions<User, Error, User, string[]>, "queryFn"> & {
queryFn?: QueryFunction<User, string[], never>;
} & {
queryKey: string[] & {
[dataTagSymbol]: User;
[dataTagErrorSymbol]: Error;
};
};
}, AnyContext, {}, undefined, readonly [Route<Register, ... 16 more ..., undefined>], unknown, unknown, unknown, undefined>, "never", false, RouterHistory, Record<...>>, unknown>(opts?: UseRouteContextBaseOptions<...> | undefined) => {
...;
}
useRouteContext
()
}

We will see all queryOptions that have been created by any parent, as long as they add it to the route context. 🎉

A Final Note on Subscriptions

When this feature made it to the router, I was a bit concerned about what our components subscribe to. As you’ve seen, we’ve basically replaced all subscriptions to useParams and useSearch with just a single call to useRouteContext:

Subscriptions
function DashboardContent() {
const params = Route.useParams()
const { asOf } = Route.useSearch()
const context = Route.useRouteContext()
}

Even with fine-grained subscriptions (which Route Context also supports), wouldn’t we get a new queryOptions object every render that isn’t structurally sharable because it contains functions? And wouldn’t that make everything re-render all the time?

Rest assured: That’s not the case. The context function only runs when either params or loaderDeps changes, so if there’s a change to an unrelated search param (like debug), your components that are subscribed to the Route Context won’t re-render unnecessarily.

All in all, I’ve found this the best and most scalable solution to ensure that our components actually consume the same things that the route loader prefetches.


That’s it for today. Feel free to reach out to me on bluesky (opens in a new window) if you have any questions, or just leave a comment below. ⬇️

Like the monospace font in the code blocks?

Check out monolisa.dev

Bytes - the JavaScript Newsletter that doesn't suck