skip to content

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?

level: middleimportance: nice to knowfreq 30%

answer

  1. a router config default
  2. changed in v22
  3. child wins on collision
  4. empty path or componentless parent

basics

~20 s

Since 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 lines
ts
import {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

for a junior

Know that nested routes can read the parent's parameters and that current Angular makes them available on the child's own route.

for a middle

Explain 'always' versus 'emptyOnly', the v22 default change, and that the child's value wins on a name clash.

for a senior

Plan the upgrade: find parent walks, name clashes and inputs that now receive inherited values, or pin 'emptyOnly' deliberately.

for a principal

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