Ch. 4 · Angular

Angular Route Parameters and Reused Components

Going from /products/1 to /products/2 reuses the component, so snapshot reads go stale. Read params with paramMap, toSignal or input binding.

~6 min readintermediateupdated Oct 7, 2026

Here’s a bug that shows up in almost every Angular codebase at some point. A product page reads the id from the URL in ngOnInit, loads the product, and works fine. Then someone adds a “Related products” strip with links to /products/2, /products/3. Clicking one changes the URL, the browser history updates, and the page keeps showing product 1.

Nothing is broken in the router. It’s doing an optimisation on purpose: when the new URL matches the same route config as the current one, Angular reuses the component instance instead of destroying and recreating it. Only the parameters change. Code that reads parameters once never sees the new value.

This note shows the reuse happening, then compares the three correct ways to read parameters in Angular 19 and 20. The log below comes from Angular 20.3’s RouterTestingHarness running under Node.

Watching the router reuse a component

The route config is ordinary:

provideRouter(
  [
    { path: '', component: Home },
    { path: 'products/:id', component: Product },
  ],
  withComponentInputBinding(),
);
TypeScript

The Product component logs from its constructor, from ngOnInit, from a paramMap subscription, and from an id input setter. It also stores the snapshot value in a field when constructed. Navigating through five URLs produced:

navigate /products/1
constructor #1, snapshot id=1
  paramMap emitted id=1
  input id=1
  ngOnInit (snapshot now 1)
navigate /products/2
  paramMap emitted id=2
  input id=2
  field snapshotId=1, route.snapshot id=2
navigate /products/2?tab=reviews
navigate /
  destroyed
navigate /products/3
constructor #2, snapshot id=3
  paramMap emitted id=3
  input id=3
  ngOnInit (snapshot now 3)
Text

Read the second block carefully. Going from product 1 to product 2, there’s no constructor and no ngOnInit. The same instance stayed on screen. The paramMap observable emitted 2, and the bound input received 2. The field that copied the snapshot at construction still says 1.

Notice also that route.snapshot itself now says 2. The router replaces the snapshot object on each navigation. So this.route.snapshot.paramMap.get('id') read at the time you need it is current; what goes stale is a value you copied out of it once. In practice the bug is almost always “read in ngOnInit, used forever”.

Only when the user leaves to / is the component destroyed, and coming back to /products/3 creates a fresh one.

Why the router does this

Destroying and recreating a component throws away its DOM, its child components and any state they hold: scroll position, an open accordion, a half-typed review. For a master-detail page where you flip through items, reuse keeps the frame stable and only the data changes. It’s also cheaper.

The rule is about route configs, not URLs. /products/1 and /products/2 both match products/:id, so they’re the same route with different params. /products/1 and /orders/1 match different configs, so the component is replaced.

You can change this with a custom RouteReuseStrategy whose shouldReuseRoute returns false, but that’s a global hammer for a local problem. Reading parameters reactively is the fix interviewers expect.

Three correct ways to read parameters

1. Subscribe to paramMap. The original approach, and still valid:

private route = inject(ActivatedRoute);
private api = inject(ProductApi);

product = toSignal(
  this.route.paramMap.pipe(
    map((p) => p.get('id')!),
    switchMap((id) => this.api.getProduct(id)),
  ),
);
TypeScript

toSignal handles unsubscribing when the component is destroyed. switchMap matters as much as the stream itself: if the user clicks product 2 and then product 3 quickly, the request for 2 is cancelled, so its late response can’t replace product 3 on screen.

2. Bind the parameter to an input. With withComponentInputBinding() in provideRouter, the router sets component inputs whose names match path params, query params, resolved data or static route data:

export class Product {
  id = input.required<string>();
  private api = inject(ProductApi);

  product = rxResource({
    params: () => this.id(),
    stream: ({ params: id }) => this.api.getProduct(id),
  });
}
TypeScript

The log shows the input receiving 2 on the reused instance. Because id is a signal input, anything derived from it reacts automatically. Here rxResource (experimental in 19 and 20; the option names shown are 20’s, in 19 they were request and loader) reloads when the id changes and cancels the previous load. This is the cleanest option for new code because the component doesn’t know about ActivatedRoute at all, which also makes it trivial to test: set the input, no router needed.

3. Read route.snapshot at the moment of use. Fine for one-off reads in event handlers, like building a share link when the user clicks a button. Not fine for anything that drives what’s on screen.

Query params, parents and other details

Query params are a separate stream. In the log, going from /products/2 to /products/2?tab=reviews produced no paramMap emission and no id input change, because the path param didn’t change. Read ?tab= through queryParamMap, or with input binding, an input named tab. Query param changes also reuse the component.

Parent params aren’t in a child’s paramMap. With users/:userId containing a child posts/:postId, the child route’s paramMap has only postId by default. Either inject the parent route (route.parent!.paramMap) or set paramsInheritanceStrategy: 'always' via withRouterConfig() so children inherit parent params. Input binding follows the same inheritance rules.

paramMap versus params. params is a plain object; paramMap has get(), getAll() and has(). Both emit the same changes. paramMap is preferred because it handles repeated params cleanly and returns null for missing ones instead of undefined.

Everything is a string. p.get('id') returns '2', not 2. With input binding you can add transform: numberAttribute to the input to convert it.

Interview tip

If a component needs to reset local UI state when the param changes (close an edit form, clear a selected tab), derive that reset from the parameter too: an effect on id(), or linkedSignal(() => { this.id(); return 'details'; }) for a selected tab that resets to “details” on each new product.

Interview practice

A user profile page at /users/:id shows a “Following” list. Clicking a followed user navigates to their profile. The URL and the page heading change correctly, but the “Following” list keeps showing the first user’s list. The heading uses {{ route.snapshot.paramMap.get('id') }} in the template, and the list is loaded in ngOnInit with this.api.following(this.route.snapshot.paramMap.get('id')). Explain why one updates and the other doesn’t, and fix it.

Answer

Both navigations match the same route config, users/:id, so the router reuses the UserProfile instance. ngOnInit ran once, for the first user, so the list request was made once with the first id and never repeated.

The heading updates because the template expression reads route.snapshot fresh on every change detection pass, and the router replaces the snapshot on each navigation. It’s correct by accident, and it’s fragile: under OnPush, nothing would mark the view dirty just because the snapshot changed (it isn’t a signal or an input), so it would go stale too.

I’d make the id reactive once and derive everything from it. With withComponentInputBinding(), add id = input.required<string>(), use {{ id() }} in the heading, and load the list with rxResource (or toSignal(toObservable(this.id).pipe(switchMap(id => this.api.following(id))))). Both cancel a stale request when the user clicks through profiles quickly. I’d also reset any per-user UI state, such as an expanded section, from the same signal. If they push on why not use a custom RouteReuseStrategy, I’d say it would work but throws away reuse for every route in the app to paper over one component reading data non-reactively.

Further reading

More in Angular

read ✓Angular · mid

Angular Pure and Impure Pipes

Why a pure pipe ignores array.push(), what pure: false costs per change detection pass, and why reading a signal inside transform goes stale.

~6 min readread →
read ✓Angular · hard

Angular Resource API for Async Data

How resource() turns a signal-driven request into value, status and error signals, cancels stale loads, and why value() can throw on error.

~6 min readread →
read ✓Angular · hard

Angular Signal Forms Overview

How Signal Forms wrap your model signal in a FieldTree, keep validation in one schema, and map server errors onto fields when you submit.

~6 min readread →
esc