Angular 23 🅰️ Route Parameters and Query Params
A route that always renders the same thing is limited. Real apps pass data through the URL — /users/42 shows user 42, /search?q=hello runs a search, /products?page=2&sort=price paginates and sorts. Angular’s router exposes that data through route parameters and query parameters. Route params are part of the path itself; query params come after the ?. Both are available through ActivatedRoute — as a snapshot for one-time reads or as an observable when the value can change without the component being recreated. This chapter covers reading both, the difference between snapshot and observable, and the patterns that make them reliable.
Key point: The URL has two parts you can read data from: the path (/users/42) and the query string (?q=hello&page=2). Route params are captured from the path pattern — :id in the config matches a segment. Query params are key-value pairs after the ?. Both are read from ActivatedRoute. The critical decision is snapshot or observable: use snapshot for one-time reads, use the observable when the same component may be reused with different params (like navigating from /users/1 to /users/2).
What route parameters are
A route parameter is a placeholder in the route path that captures a segment of the URL.
export const routes: Routes = [
{ path: 'users/:id', component: UserDetailComponent }
];
:id is the parameter. Navigating to /users/42 matches this route, and the router captures id = '42'.
Reading the parameter:
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({ /* ... */ })
export class UserDetailComponent {
private route = inject(ActivatedRoute);
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id');
console.log(id); // '42' (string) or null
}
}
route.snapshot.paramMap.get('id') returns the string value or null.
Parameters are always strings: The URL is text. paramMap.get('id') returns string | null. Convert to numbers if needed.
const id = Number(this.route.snapshot.paramMap.get('id'));
Multiple parameters:
{ path: 'users/:userId/posts/:postId', component: PostComponent }
Visiting /users/1/posts/42 captures userId = '1' and postId = '42'.
Why route params matter: They make routes parameterized. /users/42 and /users/43 render the same component with different data. That’s how a list-detail app works — one component, many URLs.
The shape: paramMap is a ParamMap — a read-only map of parameter names to values. get returns string | null; getAll returns string[] (for repeated params).
Why parameters are strings: URLs are strings. The browser doesn’t parse them into typed values. Angular passes them through as-is and leaves the conversion to you. That’s consistent with how query strings work everywhere.
Snapshot vs observable
ActivatedRoute gives the same data two ways: a snapshot (value at activation) and observables (which emit on change).
Snapshot:
const id = this.route.snapshot.paramMap.get('id');
Returns the value at the moment the component was created. Doesn’t update.
Observable:
this.route.paramMap.subscribe(params => {
const id = params.get('id');
});
Emits the current value and every subsequent change.
When the component is reused: Navigating from /users/1 to /users/2 usually reuses the same UserDetailComponent instance. Angular sees the same route, same component — no reason to destroy and recreate.
// URL: /users/1 → component created
// Snapshot: id = '1'
// Observable: emits '1'
// URL changes: /users/2 → component reused
// Snapshot: id is still '1' ← stale!
// Observable: emits '2' ← correct
The rule:
| Situation | Use |
|---|---|
| Params never change for this component | Snapshot |
| Params may change (same route reused) | Observable |
| Unsure | Observable |
Why the difference: Snapshot captures the value once. If the URL changes without recreating the component, the snapshot is outdated. The observable stays in sync.
The safe default: Use the observable. It works in both cases and never goes stale.
Unsubscribing: Route observables complete when the component is destroyed — no manual unsubscribe needed. That’s one of the router’s conveniences.
Why Angular reuses the component: Recreating a component is expensive. If the route config matches and the component class is the same, Angular reuses the instance. It only recreates when the path genuinely changes to a different route.
Why both APIs exist: Snapshot is convenient for one-time reads. The observable is correct for the reuse case. Angular provides both because they serve different needs. When in doubt, subscribe — the observable handles both.
Reading route params
The paramMap provides both get and getAll.
// Snapshot
const id = this.route.snapshot.paramMap.get('id');
// Observable
this.route.paramMap.subscribe(params => {
const id = params.get('id');
});
get vs getAll:
| Method | Returns |
|---|---|
get(key) | string | null — first value |
getAll(key) | string[] — all values |
has(key) | boolean |
Repeated params: A route like :ids can match multiple segments if configured with a repeated pattern. Rare, but getAll handles it.
Multiple parameters:
this.route.paramMap.subscribe(params => {
const userId = params.get('userId');
const postId = params.get('postId');
console.log(userId, postId);
});
Each parameter is keyed by the name from the route config.
Pattern with signals: Combine with toSignal to get a signal from the observable.
import { toSignal } from '@angular/core/rxjs-interop';
export class UserDetailComponent {
private route = inject(ActivatedRoute);
id = toSignal(this.route.paramMap.pipe(map(p => p.get('id'))));
}
id is a signal that updates on navigation.
Pattern with switchMap: Load data whenever the param changes.
this.route.paramMap.pipe(
map(params => params.get('id')),
switchMap(id => this.userService.getUser(Number(id)))
).subscribe(user => {
this.user = user;
});
The outer observable reacts to param changes; switchMap cancels the previous request and starts a new one.
Pattern with takeUntilDestroyed: Subscribe and clean up automatically.
this.route.paramMap.pipe(
takeUntilDestroyed()
).subscribe(params => {
const id = params.get('id');
this.loadUser(Number(id));
});
Route observables complete on destroy, but takeUntilDestroyed covers cases where the subscription outlives the component for other reasons.
Why paramMap and not params: paramMap has a typed API (get, getAll, has). params is a plain object — also works but less ergonomic. Use paramMap for new code.
Why
paramMapwas introduced: Earlier Angular hadparamsas an object andparamsArrayas an array.paramMapunified them —getreturns the first,getAllreturns all. It’s the modern, consistent API.
What query parameters are
Query parameters come after the ? in a URL — key-value pairs that don’t change which component renders.
/users?role=admin&page=2
role=admin and page=2 are query params. The route still matches /users.
Reading query params:
this.route.queryParamMap.subscribe(params => {
const role = params.get('role');
const page = params.get('page');
console.log(role, page);
});
// Or snapshot
const role = this.route.snapshot.queryParamMap.get('role');
queryParamMap works like paramMap — get, getAll, has.
Route params vs query params:
| Aspect | Route Params | Query Params |
|---|---|---|
| Position | In the path (/users/:id) | After ? |
| Required | Yes (part of route) | Optional |
| Affects routing | Yes | No |
| Use for | Identity | Filters, options |
| Example | /users/42 | /users?page=2 |
When to use which:
- Route params — the resource identity.
/users/42— user 42. - Query params — optional settings.
/users?page=2&sort=name— same list, different view.
Why the distinction: Route params are part of the route — a different value is a different “page.” Query params are modifiers — they don’t change the route, they change how the same route behaves.
Why query params matter: Filters, pagination, sorting, search queries — anything optional and non-identifying. They don’t break the route match, and they can be omitted.
Why use query params for filters: Because filters are optional.
/usersis valid without a filter./users?role=adminadds a filter. If the filter were a route param, you’d need two routes — one with and one without. Query params keep the route single.
Reading query params
Same API as route params, but through queryParamMap.
// Snapshot
const page = this.route.snapshot.queryParamMap.get('page');
// Observable
this.route.queryParamMap.subscribe(params => {
const page = params.get('page');
const sort = params.get('sort');
});
Defaults: Query params are optional. If missing, get returns null.
const page = this.route.snapshot.queryParamMap.get('page') ?? '1';
const sort = this.route.snapshot.queryParamMap.get('sort') ?? 'name';
Parsing types:
const page = Number(this.route.snapshot.queryParamMap.get('page') ?? '1');
Query param values are strings. Convert as needed.
Multiple values for one key:
/users?tag=admin&tag=user
const tags = this.route.snapshot.queryParamMap.getAll('tag');
// ['admin', 'user']
Why query params are optional: They’re modifiers. The route works without them. Code that reads them must handle null.
When to use snapshot vs observable:
- Snapshot — if the query params don’t change while the component is active, or you only care about the initial value.
- Observable — if the query params can change (like when the user changes a filter), and the component should react.
Common pattern: Subscribe and re-fetch on change.
this.route.queryParamMap.pipe(
takeUntilDestroyed()
).subscribe(params => {
const page = Number(params.get('page') ?? '1');
const sort = params.get('sort') ?? 'name';
this.loadUsers(page, sort);
});
The component reloads whenever the filter changes.
Why this works: Navigating from /users?page=1 to /users?page=2 reuses the same component. Only the query params change. The observable catches it; the snapshot wouldn’t.
Why query params are read the same way as route params: Consistency.
paramMapandqueryParamMapshare the same API —get,getAll,has. Learn one, learn both. The router treats them uniformly even though they mean different things.
Navigating with parameters
Setting params happens at the navigation call, not by building URLs by hand.
Route params via routerLink:
<a [routerLink]="['/users', userId]">User {{ userId }}</a>
The array ['/users', 42] becomes /users/42.
Route params via navigate:
this.router.navigate(['/users', 42]);
Query params via routerLink:
<a [routerLink]="['/users']" [queryParams]="{ page: 1, sort: 'name' }">
Users
</a>
Renders /users?page=1&sort=name.
Query params via navigate:
this.router.navigate(['/users'], {
queryParams: { page: 1, sort: 'name' }
});
Preserving query params:
this.router.navigate(['/users', 42], {
queryParamsHandling: 'preserve'
});
queryParamsHandling controls what happens to the current query params:
| Value | Effect |
|---|---|
'merge' | Merge new with existing |
'preserve' | Keep existing, ignore new |
'' (default) | Replace with new |
Replacing query params without navigation:
this.router.navigate([], {
relativeTo: this.route,
queryParams: { page: 2 },
queryParamsHandling: 'merge'
});
Navigating to [] (no path change) with relativeTo keeps the current route but updates the query params. merge keeps existing ones.
Fragment:
this.router.navigate(['/docs'], { fragment: 'section-1' });
Adds #section-1 to the URL.
Why use the API and not strings: Building URLs by hand is error-prone — special characters, encoding, nested paths. The router’s API handles encoding and validation. Use it.
Why
queryParamsHandlingexists: Query params often carry state that should persist across navigation.preservekeeps them;mergeadds to them; the default replaces them. Choosing the right behavior avoids losing filter state when navigating between pages.
A full example
A user list with filtering, pagination, and a detail route.
// ============================================
// USER SERVICE
// ============================================
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
}
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
getUsers(filter: { role?: string; page: number; sort: string }): Promise<User[]> {
const params = new URLSearchParams();
if (filter.role) params.set('role', filter.role);
params.set('page', String(filter.page));
params.set('sort', filter.sort);
return this.http
.get<User[]>(`/api/users?${params}`)
.toPromise() as Promise<User[]>;
}
getUser(id: number): Promise<User> {
return this.http.get<User>(`/api/users/${id}`).toPromise() as Promise<User>;
}
}
// ============================================
// USER LIST — reads query params
// ============================================
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [CommonModule],
template: `
<h2>Users</h2>
<div class="filters">
<label>
Role:
<select [value]="role()" (change)="setRole($any($event.target).value)">
<option value="">All</option>
<option value="admin">Admin</option>
<option value="user">User</option>
<option value="guest">Guest</option>
</select>
</label>
<label>
Sort:
<select [value]="sort()" (change)="setSort($any($event.target).value)">
<option value="name">Name</option>
<option value="id">ID</option>
</select>
</label>
</div>
<ul>
@for (user of users(); track user.id) {
<li>
<a [routerLink]="['/users', user.id]"
[queryParamsHandling]="'preserve'">
{{ user.name }} ({{ user.role }})
</a>
</li>
}
</ul>
<button (click)="prevPage()" [disabled]="page() <= 1">Prev</button>
<span>Page {{ page() }}</span>
<button (click)="nextPage()">Next</button>
`
})
export class UserListComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private userService = inject(UserService);
users = signal<User[]>([]);
role = signal('');
page = signal(1);
sort = signal('name');
constructor() {
this.route.queryParamMap
.pipe(takeUntilDestroyed())
.subscribe(params => {
this.role.set(params.get('role') ?? '');
this.page.set(Number(params.get('page') ?? '1'));
this.sort.set(params.get('sort') ?? 'name');
this.loadUsers();
});
}
private loadUsers(): void {
this.userService
.getUsers({
role: this.role() || undefined,
page: this.page(),
sort: this.sort()
})
.then(users => this.users.set(users));
}
setRole(role: string): void {
this.updateQueryParams({ role: role || null, page: 1 });
}
setSort(sort: string): void {
this.updateQueryParams({ sort });
}
prevPage(): void {
this.updateQueryParams({ page: this.page() - 1 });
}
nextPage(): void {
this.updateQueryParams({ page: this.page() + 1 });
}
private updateQueryParams(params: Record<string, string | number | null>): void {
this.router.navigate([], {
relativeTo: this.route,
queryParams: params,
queryParamsHandling: 'merge'
});
}
}
// ============================================
// USER DETAIL — reads route params
// ============================================
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs';
@Component({
selector: 'app-user-detail',
standalone: true,
template: `
@if (user()) {
<h2>{{ user()!.name }}</h2>
<p>{{ user()!.email }}</p>
<p>Role: {{ user()!.role }}</p>
} @else {
<p>Loading...</p>
}
`
})
export class UserDetailComponent {
private route = inject(ActivatedRoute);
private userService = inject(UserService);
user = signal<User | null>(null);
constructor() {
this.route.paramMap
.pipe(
takeUntilDestroyed(),
switchMap(params => {
const id = Number(params.get('id'));
return this.userService.getUser(id);
})
)
.subscribe(user => this.user.set(user));
}
}
// ============================================
// ROUTES
// ============================================
import { Routes } from '@angular/router';
export const routes: Routes = [
{ path: 'users', component: UserListComponent },
{ path: 'users/:id', component: UserDetailComponent }
];
What this shows:
- UserListComponent reads query params (
role,page,sort) viaqueryParamMap - The observable reloads users whenever filters change
updateQueryParamsnavigates withqueryParamsHandling: 'merge'to keep existing filters- UserDetailComponent reads route params (
id) viaparamMap switchMapcancels the previous request when the id changes[queryParamsHandling]="'preserve'"on the detail link keeps the filter state when viewing a user
The list reacts to filter changes; the detail reacts to id changes. Both use observables because the params can change.
Why this shape: It’s a real list-detail flow. The list reads filters from the URL so the state is shareable and bookmarkable. The detail reads the id from the URL and reloads on change. Combining both — clicking a user preserves filters — is the natural behavior.
Complete Example Session
# ============================================
# PART 1: BASIC ROUTE PARAM
# ============================================
cat > routes.ts << 'EOF'
import { Routes } from '@angular/router';
import { UserDetailComponent } from './user-detail.component';
export const routes: Routes = [
{ path: 'users/:id', component: UserDetailComponent }
];
EOF
cat > user-detail.component.ts << 'EOF'
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-user-detail',
standalone: true,
template: `<p>User ID: {{ id }}</p>`
})
export class UserDetailComponent {
private route = inject(ActivatedRoute);
id = this.route.snapshot.paramMap.get('id');
}
EOF
npx tsc --noEmit user-detail.component.ts
# (no errors)
# ============================================
# PART 2: OBSERVABLE PARAM
# ============================================
cat > reactive.ts << 'EOF'
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-reactive',
standalone: true,
template: `<p>User ID: {{ id() }}</p>`
})
export class ReactiveComponent {
private route = inject(ActivatedRoute);
id = signal<string | null>(null);
constructor() {
this.route.paramMap
.pipe(takeUntilDestroyed())
.subscribe(params => {
this.id.set(params.get('id'));
});
}
}
EOF
npx tsc --noEmit reactive.ts
# (no errors)
# ============================================
# PART 3: QUERY PARAMS
# ============================================
cat > query.ts << 'EOF'
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-query',
standalone: true,
template: `<p>Search: {{ q() }} Page: {{ page() }}</p>`
})
export class QueryComponent {
private route = inject(ActivatedRoute);
q = signal('');
page = signal(1);
constructor() {
this.route.queryParamMap
.pipe(takeUntilDestroyed())
.subscribe(params => {
this.q.set(params.get('q') ?? '');
this.page.set(Number(params.get('page') ?? '1'));
});
}
}
EOF
npx tsc --noEmit query.ts
# (no errors)
# ============================================
# PART 4: NAVIGATE WITH QUERY PARAMS
# ============================================
cat > navigate.ts << 'EOF'
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
@Component({
selector: 'app-nav',
standalone: true,
template: `<button (click)="search()">Search</button>`
})
export class NavComponent {
private router = inject(Router);
private route = inject(ActivatedRoute);
search(): void {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { q: 'hello', page: 1 },
queryParamsHandling: 'merge'
});
}
}
EOF
npx tsc --noEmit navigate.ts
# (no errors)
# ============================================
# PART 5: DATA LOADING PATTERN
# ============================================
cat > data.ts << 'EOF'
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs';
interface User { id: number; name: string; }
@Component({
selector: 'app-data',
standalone: true,
template: `@if (user()) { <p>{{ user()!.name }}</p> }`
})
export class DataComponent {
private route = inject(ActivatedRoute);
user = signal<User | null>(null);
constructor() {
this.route.paramMap
.pipe(
takeUntilDestroyed(),
switchMap(params => {
const id = Number(params.get('id'));
return Promise.resolve({ id, name: `User ${id}` });
})
)
.subscribe(u => this.user.set(u));
}
}
EOF
npx tsc --noEmit data.ts
# (no errors)
# ============================================
# PART 6: QUERY PARAMS WITH MULTIPLE VALUES
# ============================================
cat > multi.ts << 'EOF'
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-multi',
standalone: true,
template: `<p>Tags: {{ tags.join(', ') }}</p>`
})
export class MultiComponent {
private route = inject(ActivatedRoute);
tags = this.route.snapshot.queryParamMap.getAll('tag');
}
EOF
npx tsc --noEmit multi.ts
# (no errors)
# ============================================
# PART 7: PRESERVE QUERY PARAMS
# ============================================
cat > preserve.ts << 'EOF'
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
@Component({
selector: 'app-preserve',
standalone: true,
template: `<button (click)="go()">Go</button>`
})
export class PreserveComponent {
private router = inject(Router);
private route = inject(ActivatedRoute);
go(): void {
this.router.navigate(['/users'], {
queryParams: { page: 2 },
queryParamsHandling: 'merge' // keeps existing, adds page
});
}
}
EOF
npx tsc --noEmit preserve.ts
# (no errors)
# ============================================
# PART 8: SUMMARY
# ============================================
cat << 'EOF'
Reading data from the URL:
Route params (path):
/users/:id
route.snapshot.paramMap.get('id')
route.paramMap.subscribe(...)
Query params (after ?):
/users?page=2&sort=name
route.snapshot.queryParamMap.get('page')
route.queryParamMap.subscribe(...)
Snapshot vs Observable:
Snapshot → one-time read
Observable → reacts to changes (component reuse)
Navigating with params:
routerLink → template
router.navigate() → code
queryParamsHandling → merge / preserve / replace
Conversions:
paramMap.get() → string | null
Number(x) → number
x ?? 'default' → fallback
EOF
Quick Reference
Reading Route Params
| Method | Returns |
|---|---|
route.snapshot.paramMap.get('id') | string | null |
route.paramMap.subscribe(...) | Observable |
params.get('id') | string | null |
params.getAll('id') | string[] |
params.has('id') | boolean |
Reading Query Params
| Method | Returns |
|---|---|
route.snapshot.queryParamMap.get('q') | string | null |
route.queryParamMap.subscribe(...) | Observable |
params.get('q') | string | null |
params.getAll('tag') | string[] |
Route Params vs Query Params
| Aspect | Route | Query |
|---|---|---|
| Position | In path | After ? |
| Required | Yes | Optional |
| Affects route | Yes | No |
| Use for | Identity | Filters |
| Example | /users/42 | /users?page=2 |
| Property | paramMap | queryParamMap |
Snapshot vs Observable
| Use | When |
|---|---|
| Snapshot | One-time read; params won’t change |
| Observable | Params may change; component reused |
| Observable | Unsure |
| Snapshot | Simple case with no reuse |
Navigation with Params
| Form | Code |
|---|---|
| Static route param | routerLink="/users/42" |
| Dynamic route param | [routerLink]="['/users', 42]" |
| Query params | [queryParams]="{ page: 1 }" |
| Fragment | fragment="section" |
| Programmatic route | router.navigate(['/users', 42]) |
| Programmatic query | router.navigate(['/users'], { queryParams }) |
queryParamsHandling
| Value | Effect |
|---|---|
'' (default) | Replace all |
'merge' | Merge new with existing |
'preserve' | Keep existing, ignore new |
Type Conversions
| Input | Output |
|---|---|
paramMap.get('id') | string | null |
Number(x) | number |
x ?? 'default' | string |
x?.toUpperCase() | string | undefined |
Common Patterns
| Pattern | Code |
|---|---|
| Read once | snapshot.paramMap.get(...) |
| React to change | paramMap.pipe(takeUntilDestroyed()).subscribe(...) |
| Load on change | paramMap.pipe(switchMap(...)) |
| Convert to signal | toSignal(route.paramMap.pipe(map(...))) |
| Update query | router.navigate([], { queryParams, relativeTo: route }) |
| Merge queries | queryParamsHandling: 'merge' |
Parsing Numbers
| Task | Code |
|---|---|
| Safe number | Number(param ?? '0') |
| With default | Number(param ?? '1') |
| Fallback string | param ?? 'default' |
| Null check | if (param !== null) { } |
Route Config with Params
| Config | URL |
|---|---|
{ path: 'users/:id' } | /users/42 |
{ path: 'users/:userId/posts/:postId' } | /users/1/posts/2 |
{ path: ':category/:id' } | /books/42 |
{ path: 'files/*' } | /files/a/b/c (wildcard) |
Error Cases
| Error | Cause |
|---|---|
Cannot read property of null | Snapshot returned null |
| Stale value | Used snapshot with changing param |
NaN | Number(null) or Number('abc') |
| Route not matched | Missing param in config |
Related APIs
| API | Purpose |
|---|---|
ActivatedRoute | Access current route |
paramMap | Route params |
queryParamMap | Query params |
snapshot | Current value |
Router | Navigate programmatically |
RouterLink | Navigate declaratively |
Best Practices
✅ Do This:
// Use observable when params may change
this.route.paramMap.pipe(
takeUntilDestroyed()
).subscribe(params => {
const id = params.get('id');
}); // ✅
// Use snapshot for one-time reads
const id = this.route.snapshot.paramMap.get('id'); // ✅
// Convert strings to numbers
const id = Number(this.route.snapshot.paramMap.get('id')); // ✅
// Provide defaults for query params
const page = Number(this.route.snapshot.queryParamMap.get('page') ?? '1'); // ✅
// Use switchMap for data loading on param change
this.route.paramMap.pipe(
switchMap(params => this.service.get(Number(params.get('id'))))
).subscribe(); // ✅
// Preserve query params when navigating
this.router.navigate(['/users', id], { queryParamsHandling: 'preserve' }); // ✅
// Merge query params when updating one
this.router.navigate([], {
relativeTo: this.route,
queryParams: { page: 2 },
queryParamsHandling: 'merge'
}); // ✅
// Use query params for filters
// Route params for identity // ✅
// Combine with signals
id = toSignal(this.route.paramMap.pipe(map(p => p.get('id')))); // ✅
❌ Don’t Do This:
// Don't use snapshot when params change
const id = this.route.snapshot.paramMap.get('id');
// on /users/1 → /users/2, id stays '1' // ❌
// Don't use Number without a default
const page = Number(this.route.snapshot.queryParamMap.get('page'));
// → 0, not 1, when missing // ⚠️
// Don't build URLs by hand
this.router.navigateByUrl(`/users/${id}?page=${p}`); // ⚠️
// Don't forget queryParamsHandling
this.router.navigate(['/users'], { queryParams: { page: 2 } });
// drops other query params // ⚠️
// Don't subscribe without cleanup when not a route observable
someObservable.subscribe(...); // ⚠️ leaks // ⚠️
// Don't access params synchronously before activation
// route.snapshot works; route.params does not yet // ⚠️
// Don't parse without fallback for missing params
const role = this.route.snapshot.queryParamMap.get('role');
if (role.length > 0) { } // ❌ null if missing // ❌
// Don't use query params for required data
// /users (no id) → the route can't identify which user // ⚠️
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
| Snapshot with changing params | Stale value | Use observable |
Number(null) | 0 | Provide default |
| Missing query param | null | Fallback with ?? |
Forgot queryParamsHandling | Lost filters | Use merge |
| Assuming param is a number | Compile error | Convert with Number |
| Not converting from string | Type mismatch | Parse explicitly |
| Ignoring param changes | Same component reused | Subscribe |
| Building URLs manually | Encoding issues | Use router API |
| Missing param in route config | Route doesn’t match | Add :name |
| Multiple values | Only first returned | Use getAll |
Real-World Examples
1. Route param from path
this.route.snapshot.paramMap.get('id');
2. Multiple route params
const userId = params.get('userId');
const postId = params.get('postId');
3. Query param
this.route.snapshot.queryParamMap.get('q');
4. Query with default
const page = Number(this.route.snapshot.queryParamMap.get('page') ?? '1');
5. Reactive route param
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe(params => {
this.load(params.get('id'));
});
6. Reactive query param
this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe(params => {
this.filter(params.get('role'));
});
7. Switch map on route param
this.route.paramMap.pipe(
switchMap(p => this.service.get(Number(p.get('id'))))
).subscribe();
8. Route param via link
<a [routerLink]="['/users', user.id]">User {{ user.id }}</a>
9. Query param via link
<a [routerLink]="['/search']" [queryParams]="{ q: term }">Search</a>
10. Navigate with query params
this.router.navigate(['/users'], { queryParams: { page: 2 } });
11. Merge query params
this.router.navigate([], {
relativeTo: this.route,
queryParams: { page: 2 },
queryParamsHandling: 'merge'
});
12. Preserve query params
this.router.navigate(['/users', id], { queryParamsHandling: 'preserve' });
13. Get all values
const tags = this.route.snapshot.queryParamMap.getAll('tag');
14. Convert to signal
id = toSignal(this.route.paramMap.pipe(map(p => p.get('id'))));
15. Load data on change
constructor() {
this.route.paramMap.pipe(
takeUntilDestroyed(),
switchMap(p => this.service.get(Number(p.get('id'))))
).subscribe(d => this.data.set(d));
}
16. Query param toggle
toggleView(): void {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { view: 'grid' },
queryParamsHandling: 'merge'
});
}
17. Reset pagination on filter change
setFilter(role: string): void {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { role, page: 1 },
queryParamsHandling: 'merge'
});
}
18. Fragment navigation
this.router.navigate(['/docs'], { fragment: 'install' });
19. Check param presence
if (params.has('role')) {
const role = params.get('role');
}
20. Fallback for missing route param
const id = Number(this.route.snapshot.paramMap.get('id') ?? '0');
if (id === 0) {
this.router.navigate(['/404']);
}
Visual: URL Structure
┌───────────────────────────────────────────────┐
│ /users/42?tab=posts&page=2#section │
│ ─────┬───┬─ ────────────┬─────── ──┬──── │
│ │ │ │ │ │
│ │ │ │ └ fragment│
│ │ │ │ │
│ │ │ └ query params │
│ │ │ (tab, page) │
│ │ │ │
│ │ └ route param │
│ │ (id = 42) │
│ │ │
│ └ path │
│ │
└───────────────────────────────────────────────┘
Visual: Route Param vs Query Param
┌──────────────────────────────────────────────┐
│ /users/42 │
│ └── route param (id) │
│ │
│ Identifies a specific user │
│ Required to render │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ /users?page=2&sort=name │
│ └── query params │
│ │
│ Modifies how the list is shown │
│ Optional │
│ │
└──────────────────────────────────────────────┘
Visual: Snapshot vs Observable
┌──────────────────────────────────────────────┐
│ URL: /users/1 │
│ Component created │
│ │
│ Snapshot: '1' │
│ Observable emits: '1' │
│ │
└──────────────────────────────────────────────┘
│
│ navigate /users/2
▼
┌──────────────────────────────────────────────┐
│ Component reused (not recreated) │
│ │
│ Snapshot: '1' ← still the old value │
│ Observable emits: '2' ← updated ✅ │
│ │
└──────────────────────────────────────────────┘
Visual: Reading Params
┌──────────────────────────────────────────────┐
│ ActivatedRoute │
│ │
│ ├── snapshot │
│ │ ├── paramMap │
│ │ │ └── get('id') │
│ │ └── queryParamMap │
│ │ └── get('page') │
│ │ │
│ ├── paramMap (observable) │
│ │ └── subscribe(...) │
│ │ │
│ └── queryParamMap (observable) │
│ └── subscribe(...) │
│ │
└──────────────────────────────────────────────┘
Visual: Data Loading on Param Change
┌──────────────────────────────────────────────┐
│ URL changes: /users/1 → /users/2 │
│ │ │
│ ▼ │
│ paramMap emits new value │
│ │ │
│ ▼ │
│ switchMap cancels previous request │
│ │ │
│ ▼ │
│ Service loads new data │
│ │ │
│ ▼ │
│ Signal updated → view rerenders │
│ │
└──────────────────────────────────────────────┘
Visual: Query Param Update
┌──────────────────────────────────────────────┐
│ Current URL: /users?page=1&sort=name │
│ │
│ User clicks "Next" │
│ │ │
│ ▼ │
│ router.navigate([], { │
│ relativeTo: this.route, │
│ queryParams: { page: 2 }, │
│ queryParamsHandling: 'merge' │
│ }) │
│ │
│ Result: /users?page=2&sort=name │
│ (sort preserved, page updated) │
│ │
└──────────────────────────────────────────────┘
Visual: queryParamsHandling
┌──────────────────────────────────────────────┐
│ Current: ?page=1&sort=name │
│ New: { page: 2 } │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ '' (default): │
│ Result: ?page=2 │
│ (sort lost) │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ 'merge': │
│ Result: ?page=2&sort=name │
│ (sort kept, page replaced) │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ 'preserve': │
│ Result: ?page=1&sort=name │
│ (new ignored, old kept) │
│ │
└──────────────────────────────────────────────┘
Visual: Type Conversions
┌──────────────────────────────────────────────┐
│ paramMap.get('id') │
│ │ │
│ ▼ │
│ string | null │
│ │ │
│ ├── as-is → string │
│ ├── Number() → number | NaN │
│ ├── ?? 0 → string │
│ └── parseInt → number │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Safe conversion: │
│ │
│ Number(params.get('id') ?? '0') │
│ │
│ → 0 if missing │
│ → NaN if non-numeric │
│ │
└──────────────────────────────────────────────┘
Visual: Component Reuse
┌──────────────────────────────────────────────┐
│ URL: /users/1 │
│ │ │
│ ▼ │
│ Create UserDetailComponent │
│ │ │
│ ▼ │
│ ngOnInit runs once │
│ │
└──────────────────────────────────────────────┘
│
│ navigate /users/2
▼
┌──────────────────────────────────────────────┐
│ Angular matches same route config │
│ │ │
│ ▼ │
│ Reuses UserDetailComponent │
│ │ │
│ ▼ │
│ ngOnInit does NOT run again │
│ │ │
│ ▼ │
│ Only param observable fires ✅ │
│ │
└──────────────────────────────────────────────┘
Visual: Decision Flow
┌──────────────────────────────────────────────┐
│ Reading a param? │
│ │ │
│ ├── Will it change? │
│ │ │ │
│ │ ├── Yes ──► subscribe to paramMap│
│ │ │ │
│ │ └── No ──► snapshot │
│ │ │
│ └── Unsure ──► subscribe │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Which param type? │
│ │ │
│ ├── Identity (required) ──► route param│
│ │ │
│ └── Filter (optional) ──► query param │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Navigating with params? │
│ │ │
│ ├── Template ──► routerLink │
│ │ │
│ ├── Code ──► router.navigate │
│ │ │
│ └── Update query only ──► navigate([], │
│ { queryParamsHandling: 'merge' }) │
│ │
└──────────────────────────────────────────────┘
Summary
| Concept | Meaning |
|---|---|
| Route param | Part of the path — /users/:id |
| Query param | After ? — /users?page=2 |
paramMap | Route params API |
queryParamMap | Query params API |
| Snapshot | Value at activation |
| Observable | Emits on change |
queryParamsHandling | Merge, preserve, replace |
getAll | Multiple values |
get | First value |
Key takeaways:
- Route params are part of the path —
:idmatches a segment - Query params are after
?— optional modifiers - Read both through
ActivatedRoute—paramMapandqueryParamMap - Use snapshot for one-time reads; use the observable when params may change
- Angular reuses the component when only params change — snapshot goes stale
getreturnsstring | null;getAllreturnsstring[]- Param values are strings — convert with
Number()and provide defaults with?? - Navigate with params via
[routerLink]="['/users', id]"orrouter.navigate(['/users', id]) - Query params go in
{ queryParams: {...} } queryParamsHandling: 'merge'keeps existing query params when adding new onespreservekeeps the existing ones and ignores the new- Use
switchMapto load data on every param change - Route params identify a resource; query params configure the view
- Combine with signals via
toSignalfor reactive param reads
Remember: URLs carry state. Route params identify which resource; query params configure how it’s shown. Read them from ActivatedRoute — but pick the right tool: snapshot for one-time reads, observable when params may change. Angular reuses components, so snapshot goes stale. The observable is the safe default. Navigate with the router’s API, not hand-built strings. And use queryParamsHandling: 'merge' when updating filters so you don’t lose the rest of the state. That’s the whole craft of parameterized routes.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!