Reliable Query Prefetching with TanStack Router
- No translations available.
- Add translation
- #1: The Beauty of TanStack Router
- #2: Context Inheritance in TanStack Router
- #3: TanStack Router and Query
- #4: Reliable Query Prefetching with TanStack RouterCurrent
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 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:
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:
- Your components usually won’t stay in the
Routefile.
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:
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.
- 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.
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:
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:
- Load data in the route loader for “today” (as we haven’t adapted the
asOfparam in the route loader) - Wait until that data is available, then render the component
- Now fetch again from within the component with the
asOfparam present - Since
asOfwill have to be a part of ourQueryKey, we will get a completely new Query, thus we’ll re-suspend the component until that data is available to
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?
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).
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.
Let’s compare the two query related calls again - the one in the loader and the one in the component:
//loaderawait context.queryClient.ensureQueryData( dashboardQueryOptions(params.dashboardId, deps),)
// componentconst { 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.
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:
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:
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. 🎉
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:
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. ⬇️