Context Inheritance in TanStack Router
— ReactJs, TanStack Router, TypeScript — 3 min read

- #1: The Beauty of TanStack Router
- #2: Context Inheritance in TanStack Router
- No translations available.
- Add translation
TanStack Router has a lot of great features, so it's hard to pick favorites. That said, there is one thing that blew my mind once I saw it in action, and that is how the router lets you accumulate state between nested routes - not just at runtime, but also on type-level.
This feature works in a type-safe and fully inferred way for all parent-child route relations, but let's start with the most simple one where I think you'd be surprised if that didn't work:
Path Params
To show a quite minimal example, let's just take two nested routes:
1- dashboard.$dashboardId2 - route.tsx3 - widget.$widgetId4 - index.tsx
If we look at the child route (a widget on a dashboard), we can see that we get all params from our hierarchy back when calling Route.useParams()
:
1export const Route = createFileRoute(2 '/dashboard/$dashboardId/widget/$widgetId/'3)({4 component: Widget,5})6
7function Widget() {8 const params = Route.useParams()9 // ^? { dashboardId: string, widgetId: string }10}
This is literally what you'd expect from a type-safe router, after all, $dashboardId
is right there in the path next to $widgetId
, so why is this cool?
Well, what if we'd want to express that $dashboardId
is a number? We would define that on the parent route by parsing the params
with our favourite validation library:
1import { type } from 'arktype'2
3export const Route = createFileRoute('/dashboard/$dashboardId')({4 component: Dashboard,5 params: {6 parse: type({ dashboardId: 'string.integer.parse' }).assert,7 },8})
This change leads to something amazing: Every child route in the route tree now knows about this. The docs simply say that "once a path param has been parsed, it is available to all child routes", but look what happens to our types:
1function Widget() {2 const params = Route.useParams()3 // ^? { dashboardId: number, widgetId: string }4}
The Widget
now knows that the dashboardId
is a number
. Just like that. It knows. 🤯
Let that sink in for a minute, because it has some cool implications. Because if we can define things on the parent and have the children know about their types for path params, what stops us from applying the same concept to other state our router manages?
Nothing stops us, that's what. And in fact, there are a couple of other places where this inheritance works as well:
Search Params
Yes, searchParams
can inherit context on type-level from their parents, too. Let's say we want to have an optional ?debug
boolean flag available everywhere in our app. All we need to do is define it on our root component with:
1export const Route = createRootRouteWithContext<RouteContext>()({2 validateSearch: type({ debug: 'boolean=false' }).assert,3 component: Root,4})
and now any component will get access to that boolean flag via useSearch
:
1function Widget() {2 const search = Route.useSearch()3 // ^? { debug: boolean }4}
If we add more search params in our tree, they will be merged on type-level to produce the most accurate result. For example, our widget route might get a date range filter:
1export const Route = createFileRoute(2 '/dashboard/$dashboardId/widget/$widgetId/'3)({4 validateSearch: type({ 'range?': "'7d' | '30d' | '90d'" }).assert,5 component: Widget,6})7
8function Widget() {9 const search = Route.useSearch()10 // ^? { debug: boolean, range?: '7d' | '30d' | '90d' }11}
But if we used useSearch
on the dashboard route, we would only get access to the debug
flag. This merging is insanely powerful, because it makes sure that every component gains access to all the state available throughout its parent route hierarchy. All it needs to do is to declare which route is used on.
Router Context
Another property of the Router that can do inheritance is the Router Context. This context is created at the root route if we use createRootRouteWithContext
, and it's initial values are passed to createRouter
itself. It is generally used for dependency injection into route loaders. When used with TanStack Query, we usually use it to distribute the QueryClient
:
1const queryClient = new QueryClient()2
3const router = createRouter({4 routeTree,5 context: {6 queryClient,7 },8 Wrap: ({ children }) => {9 return (10 <QueryClientProvider client={queryClient}>11 {children}12 </QueryClientProvider>13 )14 },15})
Then, this queryClient
instance is available in all loaders:
1export const Route = createFileRoute('/dashboard/$dashboardId')({2 loader: async ({ context, params }) => {3 // ^? { queryClient: QueryClient }4 await context.queryClient.ensureQueryData(5 dashboardQueryOptions(params.dashboardId)6 )7 },8 component: Dashboard,9})10
11function Dashboard() {12 const params = Route.useParams()13 const { data } = useSuspenseQuery(14 dashboardQueryOptions(params.dashboardId)15 )16}
This is great, but Route Context is more than just dependency injection. Again, the docs state that "that you can modify the context at each route and the modifications will be available to all child routes."
Route Context
To modify the context for a specific route, we can define the beforeLoad
function and return whatever we want:
1export const Route = createFileRoute('/dashboard/$dashboardId')({2 beforeLoad: () => ({ hello: 'world' } as const),3 loader: async ({ context, params }) => {4 // ^? {5 // queryClient: QueryClient;6 // readonly hello: "world";7 // }8 await context.queryClient.ensureQueryData(9 dashboardQueryOptions(params.dashboardId)10 )11 },12 component: Dashboard,13})
Whatever we return will not only be available to this route's loader, but also wherever we consume the context in child routes, for example, with useRouteContext
:
1function Widget() {2 const { hello } = Route.useRouteContext()3 // ^? "world"4}
Okay, so the context can also evolve and inherit values from its parents, but where would that be useful? Of course we can use it for things like building generic breadcrumbs, but I think I've found a killer use-case for React Query as well that I will be writing about in the next blog post.
That's it for today. Feel free to reach out to me on bluesky if you have any questions, or just leave a comment below. ⬇️
