skip to content

An Angular /users/:id profile loads its user in ngOnInit from route.snapshot; Next and Previous links change the URL but the page never updates. Why, and how do you fix it robustly?

level: seniorimportance: should knowfreq 55%

answer

  1. the instance survived
  2. loaded once, never again
  3. derive the load from the id
  4. cancel the previous load
  5. reset per-user state

basics

~20 s

The router reuses the UserProfile instance for /users/2 because it matches the same route config, so ngOnInit and its snapshot read never run again. Derive the load from paramMap or a bound input, cancel superseded loads, and reset per-user state.

solid answer

~40 s

The default `RouteReuseStrategy` keeps the component when the next navigation matches the same route config, so `/users/1` to `/users/2` updates the existing `ActivatedRoute` but never re-runs `ngOnInit`. The load that ran there used user 1's snapshot and nothing triggers another. The fix is to make loading a **function of the id stream**: `route.paramMap.pipe(map(...), switchMap(id => api.getUser(id)))`, or with `withComponentInputBinding()`, an `id` input feeding a resource keyed on it. Both reload on every id change and drop the previous request, so fast clicking cannot show an older user last. Then audit per-instance state such as form drafts, the selected tab or an expanded panel, which reuse also carries over. `onSameUrlNavigation: 'reload'` does not help, because the URL changes, and disabling reuse globally is a heavy workaround.

code

ts · 27 lines
ts
import {Component, inject, linkedSignal} from '@angular/core';
import {toSignal} from '@angular/core/rxjs-interop';
import {ActivatedRoute, RouterLink} from '@angular/router';
import {map, switchMap} from 'rxjs';
import {UserApi} from './user-api';

@Component({
  selector: 'app-user-profile',
  imports: [RouterLink],
  template: `
    <h1>{{ user()?.name }}</h1>
    <textarea [value]="note()" (input)="note.set($any($event.target).value)"></textarea>
    <a [routerLink]="['/users', id() - 1]">Previous</a>
    <a [routerLink]="['/users', id() + 1]">Next</a>
  `,
})
export class UserProfile {
  private readonly route = inject(ActivatedRoute);
  private readonly api = inject(UserApi);

  readonly id = toSignal(this.route.paramMap.pipe(map((p) => Number(p.get('id')))), {requireSync: true});
  readonly user = toSignal(
    this.route.paramMap.pipe(switchMap((p) => this.api.getUser(Number(p.get('id'))))),
  );
  // Resets to empty whenever id changes
  readonly note = linkedSignal({source: this.id, computation: () => ''});
}

go deeper

for a junior

Recall that the router can keep the same component when only the id changes, so code that runs once will not rerun.

for a middle

Explain the reuse rule, why ngOnInit and a snapshot read do not rerun, and how paramMap or an input binding fixes it.

for a senior

Diagnose from logs, drive the load from the id stream with cancellation, reset per-user state, and reject reload flags and reuse hacks.

for a principal

Make reactive route reading the default for detail pages in reviews and templates, since the bug appears whenever a page later links to itself.

## Reproducing the bug ```ts export class UserProfile implements OnInit { private readonly route = inject(ActivatedRoute); private readonly api = inject(UserApi); user?: User; ngOnInit() { const id = this.route.snapshot.paramMap.get('id')!; this.api.getUser(id).subscribe((u) => (this.user = u)); } } ``` The template has Previous and Next links to `/users/1` and `/users/3`. The first visit works. Clicking Next changes the address bar to `/users/3`, and nothing on screen changes. ## Diagnosis 1. **Reuse.** When the router processes `/users/3`, it asks the `RouteReuseStrategy` whether to keep the current route. The default `shouldReuseRoute()` returns `true` when both snapshots share the same route config object, and `users/:id` is one config. The component instance and its `ActivatedRoute` are kept. 2. **One-shot code.** `ngOnInit` runs once per instance, so the load inside it ran for user 2 and never again. 3. **The route did update.** The router replaced `route.snapshot` and emitted a new `ParamMap` on `route.paramMap`, but nothing in the component was listening. A quick confirmation: log in the constructor and in a `paramMap` subscription, then click Next. The constructor logs once; the subscription logs twice. ## A robust fix with the Observable Derive the load from the id stream so every new id triggers a new load: ```ts readonly user = toSignal( this.route.paramMap.pipe( map((p) => p.get('id')!), switchMap((id) => this.api.getUser(id)), ), ); ``` Three properties matter: - it runs for the first id and for every later one, reuse or not; - `switchMap` unsubscribes from the previous request when a new id arrives, so three quick clicks on Next cannot end with an older response overwriting the newest user; - `toSignal()` subscribes in an injection context and cleans up with the component. ## A signal-first fix with input binding With `provideRouter(routes, withComponentInputBinding())`, declare `id = input.required<string>()`. The router sets it on every navigation, including reused ones. Feed it into a resource keyed on the id, or into a `computed()` for synchronous derivations, and the page follows the URL without touching `ActivatedRoute`. ## State that reuse also keeps The instance survives, so **everything** on it survives: - a half-typed note for user 2 appears on user 3's page; - the selected tab, scroll position of an inner panel, or "expanded" flags carry over; - child components keep their own state too. Reset such state when the id changes: derive it from the id with `linkedSignal()`, or reset it in the same pipeline that loads the user. ## Fixes that are not fixes | Attempt | Why it fails or costs too much | |---|---| | Move the snapshot read to the constructor | still runs once per instance | | `onSameUrlNavigation: 'reload'` | governs navigation to the **same** URL; `/users/3` is different, and reuse is decided elsewhere | | A custom `RouteReuseStrategy` that never reuses | destroys and rebuilds the whole page per click, losing intended state and costing render time | | `window.location.reload()` | throws away the SPA state and reloads the app | ## Testing the fix A regression test should reproduce the reuse, not just the first load. With the router's testing harness you navigate to `/users/2`, then to `/users/3` **without** recreating the component, and assert that the second user is displayed and the first request's late response is ignored. A test that only mounts the component once with a fixed id passes for the broken version too, which is how this bug usually ships. ## What a strong answer adds A senior answer names reuse as the root cause, fixes the data flow rather than the lifecycle, handles out-of-order responses, and remembers the per-instance state. It also notes that the same bug appears whenever a page can link to another instance of itself: related users, search results that navigate the same route, or a Next link added long after the page was written.

  • Why is switchMap preferred over mergeMap when loading from paramMap?
    switchMap unsubscribes from the previous inner request when a new id arrives, so only the latest id's response can reach the page. mergeMap keeps every request alive, and a slow response for an older id can land last and overwrite the newer user.
  • When would a custom RouteReuseStrategy be the right tool here?
    Almost never for this bug. It changes reuse for the whole app or a set of routes and rebuilds components per navigation. It fits deliberate caching of detached pages, not fixing a component that reads its params once.

saying these in an interview costs you the question

  • Blaming change detection and calling detectChanges to refresh the page
  • Setting onSameUrlNavigation to 'reload' to force the component to reload
  • Moving the snapshot read from ngOnInit into the constructor
  • Using mergeMap so older responses can overwrite the newest user
  • Forgetting that drafts and tab state carry over to the next user
  • Disabling route reuse globally as the default fix