In Angular 22, why can a child route's component read its parent's :userId from its own ActivatedRoute params, and what did 'emptyOnly' change?
answer
- a router config default
- changed in v22
- child wins on collision
- empty path or componentless parent
basics
~20 sSince v22, paramsInheritanceStrategy defaults to 'always', so every route inherits its ancestors' params, static data and resolved data, and its own values win on a clash. The old default 'emptyOnly' inherited only for empty-path routes or componentless parents.
solid answer
~40 s`paramsInheritanceStrategy` is a `withRouterConfig()` option that decides how params, static data and resolved data flow down the route tree. Since Angular 22 the default is `'always'`: a `posts/:postId` child under `users/:userId` sees both `userId` and `postId` in its own `paramMap`, and with `withComponentInputBinding()` both bind to the child's inputs. On a key collision the child's own value wins. Before v22 the default was `'emptyOnly'`: a route inherited only when its own path was empty or its parent had no component, which is why older code walks `route.parent?.snapshot` to reach an ancestor's id. `withRouterConfig({paramsInheritanceStrategy: 'emptyOnly'})` restores the old behaviour. When upgrading, look for parent and child params sharing a name, and for inputs that now receive inherited values.
code
ts · 14 linesimport {Component, inject} from '@angular/core';
import {ActivatedRoute} from '@angular/router';
// Route: users/:userId -> children: posts/:postId, URL /users/7/posts/3
@Component({
selector: 'app-post-detail',
template: `<p>User {{ userId }}, post {{ postId }}</p>`,
})
export class PostDetail {
private readonly route = inject(ActivatedRoute);
// Angular 22 default 'always': '7'. With 'emptyOnly': null.
readonly userId = this.route.snapshot.paramMap.get('userId');
readonly postId = this.route.snapshot.paramMap.get('postId');
}go deeper
Know that nested routes can read the parent's parameters and that current Angular makes them available on the child's own route.
Explain 'always' versus 'emptyOnly', the v22 default change, and that the child's value wins on a name clash.
Plan the upgrade: find parent walks, name clashes and inputs that now receive inherited values, or pin 'emptyOnly' deliberately.
Use the default change to simplify deep feature trees, while keeping route param names unique enough that inheritance never hides data.
## The setting Angular's router builds an `ActivatedRouteSnapshot` for every matched route. Each snapshot's `params` and `data` start with the route's own values, and `paramsInheritanceStrategy` decides whether the **parent's** values are merged in. It is a `RouterConfigOptions` field, set with `provideRouter(routes, withRouterConfig({...}))`: | Value | A route inherits its parent's params, data and resolved data when... | |---|---| | `'always'` (default since v22) | always | | `'emptyOnly'` (default before v22) | its own `path` is `''`, or the parent has no component (and no `loadComponent`) | Because each parent has already merged its own parent's values, `'always'` makes every ancestor's params visible to a deep child. ## An example tree ```ts export const routes: Routes = [ { path: 'users/:userId', component: UserShell, children: [{path: 'posts/:postId', component: PostDetail}], }, ]; ``` On `/users/7/posts/3`, `PostDetail` injects its own `ActivatedRoute`: - with `'always'`: `paramMap.get('userId')` is `'7'` and `paramMap.get('postId')` is `'3'`; - with `'emptyOnly'`: `userId` is `null` here, because the child has a non-empty path and the parent has a component; code had to read `route.parent?.snapshot.paramMap.get('userId')`. ## Collisions and precedence The merge spreads the parent first and the child second, so **the child's own value wins** on the same key. If both levels are named `:id`, the child's `id` hides the parent's, so a child that needs both values must use distinct names such as `:userId` and `:postId`. A child that only wants its own `id` is unaffected by the upgrade. For data, inherited resolved data overrides inherited static data, the child's static `data` overrides both, and the child's own resolved data overrides everything. ## Why the default changed Nested layouts are the norm: `/org/:orgId/projects/:projectId/...`. Under `'emptyOnly'` every deep component walked `parent.parent.snapshot`, which couples it to the exact tree shape and breaks when a level is added. With `'always'` the component asks its own route for the value, and `withComponentInputBinding()` can bind `orgId` straight to an input of a deep routed component, because input binding reads the route's merged params and data. ## Upgrade checklist 1. Search for `parent?.snapshot`, `parent.paramMap` and similar walks; they still work but can often be simplified. 2. Look for **name clashes** between parent and child params or data keys. 3. With input binding, check routed components whose inputs share a name with an **ancestor's** param or data key; they now receive a value where they previously got `undefined`. 4. If the team is not ready, pin the old behaviour explicitly with `withRouterConfig({paramsInheritanceStrategy: 'emptyOnly'})` and plan the cleanup. ## Interaction with input binding Because `withComponentInputBinding()` binds from the route's merged params and data, the v22 default widens what reaches a routed child's inputs. `PostDetail` can now declare `userId = input.required<string>()` and receive `'7'` without any parent walk. The flip side is the undefined-default trap in reverse: an input that used to receive `undefined` because its name only existed on an ancestor now receives that ancestor's value. Name inputs after the parameter they expect and review ancestors' `data` keys, which are inherited too. ## Scope notes - Query params and the fragment are not part of this: they belong to the whole URL and every route sees them anyway. - The setting affects `params` and `data` on snapshots and the matching Observables; the URL itself is unchanged.
- If parent and child routes both declare :id, which value does the child route's paramMap return?The child's own. Inheritance spreads the parent's params first and the child's on top, so a same-named child param hides the parent's. Rename one of them, for example :userId and :postId, when the child needs both.
- How do you restore the pre-v22 behaviour for an app not ready to migrate?Pass withRouterConfig({paramsInheritanceStrategy: 'emptyOnly'}) to provideRouter(), or the same option to RouterModule.forRoot(). Routes then inherit only when their path is empty or the parent has no component.
saying these in an interview costs you the question
- Believing child routes never see parent params without walking route.parent
- Thinking the parent's value wins when parent and child params share a name
- Assuming paramsInheritanceStrategy also governs query parameters
- Stating 'emptyOnly' is still the default in Angular 22
- Expecting inheritance to change the URL the router generates