We use cookies to enhance your experience on the site
CodeWorlds

Route parameters - reading the order off the address

At your last stop you mapped out the Tokaido roads, and among them sat this entry:

{ path: 'samurai/:id', component: SamuraiDetailComponent }
. The colon in front of
id
told the router that this stretch of the address is variable. Opening
/samurai/7
brings up the profile screen, opening
/samurai/128
brings up exactly the same screen - and that is where the trouble starts. The component that appears has no idea which warrior it is supposed to be describing. The road carried it to the right door and then fell silent.

In the Academy it looks like this, @name: a runner arrives from an outpost and hands you a sealed scroll. The seal carries a warrior's number, and in the margin someone has jotted down the small stuff - how to sort the roster, which page to start from. The road delivered the scroll to the gate, but nobody is going to read it on your behalf. Your job is to break the seal. You will learn two things here: how to read the address you are standing on, and how to issue the order to march to a different address straight from the code of your class.

The scroll of the active route - ActivatedRoute

Angular keeps the description of the route that is currently open inside a service named

ActivatedRoute
. A service is a ready-made object that the framework builds by itself and hands you on request - you do not create it with
new
, you ask for it. The asking is done with the
inject()
function from the
@angular/core
package: you name the service, you get back a working instance. That gesture is called dependency injection, and you will take it apart piece by piece in the module on the craftsmen's guilds. Here, one sentence is enough:
inject(ActivatedRoute)
returns the scroll describing the route you are standing on right now.

Learn straight away what is not worth hunting for. The names

RouteParams
and
NavigationService
sound perfectly sensible, but in Angular they simply do not exist - the
@angular/router
package exports nothing of the sort, and the compiler will reject such an import outright. There is a
Router
service, which you will meet in the second half of the lesson, except that it exists to carry you between routes, not to read from the one you are on. For reading parameters there is exactly one address:
ActivatedRoute
.

1import { Component, inject } from '@angular/core';
2import { ActivatedRoute } from '@angular/router';
3
4@Component({
5  selector: 'app-samurai-detail',
6  standalone: true,
7  template: '<h1>Samurai profile</h1>'
8})
9export class SamuraiDetailComponent {
10  private route = inject(ActivatedRoute);
11}

On screen nothing changed - the template still shows the same heading, with no number anywhere in it. The only thing that changed is that the

route
field now holds the scroll, and you can look inside it. Notice two details. First, there is no constructor here at all: you call
inject()
right at the field declaration, and by that moment Angular already has the service prepared. Second, the word
private
says that the scroll is the class's internal business - the template does not reach for it, and it should not.

One glance at the gate - the route snapshot

The simplest way to read the scroll is to look at it once, at the gate.

ActivatedRoute
has a field called
snapshot
, which is exactly what the name says: the frozen state of the route at the moment you ask for it. Inside the snapshot sits
paramMap
, the map of path parameters - an object of type
ParamMap
that offers a
get(name)
method. You pass it the parameter name without the colon, so
'id'
, and you get back a value, or
null
when the address holds no such parameter.

The value always comes back as text, even for

/samurai/7
- you get the string
'7'
, not a number. And since
get()
may return
null
, that case has to be planned for. The
??
operator does the job; it is called the nullish coalescing operator, and it hands you the right-hand side only when the left-hand side is
null
or
undefined
. The whole read is a chain of four links on a single line:
this.route
, then
.snapshot
, then
.paramMap
, and finally
.get('id')
. We will put it inside
ngOnInit
, the same lifecycle hook you met during the warrior's rituals, which means the class also declares
implements OnInit
.

1import { Component, inject, OnInit } from '@angular/core';
2import { ActivatedRoute } from '@angular/router';
3
4@Component({
5  selector: 'app-samurai-detail',
6  standalone: true,
7  template: '<h1>Samurai #{{ samuraiId }}</h1>'
8})
9export class SamuraiDetailComponent implements OnInit {
10  private route = inject(ActivatedRoute);
11  samuraiId = '';
12
13  ngOnInit(): void {
14    this.samuraiId = this.route.snapshot.paramMap.get('id') ?? '';
15  }
16}

Opening

/samurai/7
now shows "Samurai #7". What did not change: the routes file. The
samurai/:id
entry looks exactly as it did yesterday, and the router still pushes nothing into the component by itself - it is the component that reaches for the data. Watch out for one trap: the name
:id
in the route config and the string
'id'
in
get('id')
must match letter for letter. A typo raises no error at all; it quietly returns
null
,
??
substitutes the empty string, and you end up staring at a blank heading and blaming the router.

Notes in the margin - query parameters

A path parameter is mandatory: the address

/samurai
with no number does not match the
samurai/:id
route at all, because a whole segment is missing, so the router looks for a different route or shows an error screen. But scrolls have a margin too, where the runner scribbles the optional things - the sort order, the page number, the chosen filter. In an address those notes live after the question mark, as in
/samurai?sort=name
, and they are called query parameters. Angular keeps them in a separate map named
queryParamMap
, served by the very same
get()
method and following the very same rule that a missing parameter yields
null
.

There is one difference, and it matters: query parameters take no part in route matching. The addresses

/samurai/7
and
/samurai/7?sort=level
land on the same route and the same component. That makes them the right place for everything the screen can open correctly without. Below I add one field and one line of reading to the class you already know.

1// the same class, imports unchanged
2sortOrder = 'default';
3
4ngOnInit(): void {
5  this.samuraiId = this.route.snapshot.paramMap.get('id') ?? '';
6  this.sortOrder = this.route.snapshot.queryParamMap.get('sort') ?? 'default';
7}

Opening

/samurai/7?sort=level
now gives you
samuraiId
equal to
'7'
and
sortOrder
equal to
'level'
, while plain
/samurai/7
leaves
sortOrder
holding the fallback
'default'
. Notice that neither the way of reading nor the handling of a missing value changed - the only thing that changed is which map you reach into:
paramMap
for path segments,
queryParamMap
for the notes after the question mark. That is the single distinction you have to keep straight here.

When one glance stops being enough

Now the most important moment of the lesson. You are standing on

/samurai/7
and you click a "next warrior" link pointing at
/samurai/8
. The address in the bar changes, the router matches the same route and the same component, so it does not build it again - it reuses the existing instance. And since the component is not created anew,
ngOnInit
is never called a second time. Your
samuraiId
field keeps the seven while the address bar clearly shows an eight.

It is worth understanding exactly where the fault lies, because it is not what intuition suggests. The snapshot itself is refreshed on every navigation - if you read

this.route.snapshot.paramMap.get('id')
after the transition, you would get the eight. The problem is that nobody reads it any more, because there is no occasion to. What you need is something that notifies you of the change by itself.

That is what

paramMap
read without
snapshot
, straight off
ActivatedRoute
, is for. It is not a single value but a stream of values arriving over time, in other words an
Observable
. To get anything out of it you sign up for notifications with the
subscribe()
method, handing it a function that Angular will call for every new value. In Academy terms:
snapshot
is a scroll collected once at the gate, while
subscribe()
is a standing runner who comes sprinting with a fresh scroll every time the order changes.

1ngOnInit(): void {
2  this.route.paramMap.subscribe(params => {
3    this.samuraiId = params.get('id') ?? '';
4  });
5}

The first line opens the subscription, the second performs the read, the third closes the callback and the call - and that is the order in which this code sits in the file. The

params
object is of exactly the same type as
snapshot.paramMap
, so the
get()
method and the
null
handling through
??
look identical. Angular calls that function immediately when you enter the route, and then once more on every move to another number. Nothing changed except the moment of reading: it went from one-off to repeatable.

When should you reach for which? Here is a simple rule, @name: if the view contains any link at all leading to the same route with a different parameter, take the stream. In every other case the snapshot is shorter, easier to read, and leaves no subscription behind. A subscription you do open ought to be cleaned up when the component is destroyed, that is in

ngOnDestroy
- the tools that do this elegantly are waiting for you with the ninja scouts, in the module on RxJS. There is also a third road: the
withComponentInputBinding()
option passed when configuring the router makes route parameters flow directly into the component's inputs. It needs knowledge of signals that you will pick up later, so today we stay with
ActivatedRoute
.

The order to march - the Router service

Up to now

routerLink
in the template did the moving for you. Very often, though, the decision to go somewhere is taken in code: after a form is saved, after a successful login, after a warrior is picked off a list. That is when you need the second service from
@angular/router
, the one called
Router
. The division of duties is sharp and worth memorising once and for all:
ActivatedRoute
describes where you stand,
Router
carries you elsewhere.

Two other names get confused here.

RouterOutlet
is not a service but a directive - the
<router-outlet>
tag that marks, inside a template, the spot where the route's component gets rendered; you cannot inject it in order to go anywhere.
Location
from the
@angular/common
package, in turn, is a thin wrapper over the browser's history, with methods such as
back()
and
go()
. It can send the user back or swap the address, but it knows nothing about your route configuration and does not trigger matching. For going to a route defined in the routes file there is
Router
, and only
Router
.

1import { Component, inject } from '@angular/core';
2import { Router } from '@angular/router';
3
4@Component({
5  selector: 'app-samurai-list',
6  standalone: true,
7  template: '<button (click)="goToSamurai(7)">Show warrior</button>'
8})
9export class SamuraiListComponent {
10  private router = inject(Router);
11
12  goToSamurai(id: number): void {
13    this.router.navigate(['/samurai', id]);
14  }
15}

Clicking the button moves the user to

/samurai/7
, and there the component from the first half of the lesson speaks up and reads the seven. The most important detail sits inside the brackets: the
navigate()
method takes an array of segments, not a finished string. Writing
['/samurai', id]
means "the samurai route, and this number inside it", and it is Angular that glues the pieces together with a slash and encodes any special characters in them. A number in the array is perfectly allowed - the router turns it into text, though on the reading side it will still come back as a string. The whole line reads left to right:
this.router
, then
.navigate(
, then
['/samurai', id]
, and
);
at the end.

Margin notes on the way out - queryParams

Since an address can carry query parameters, there has to be a way of adding them during navigation. The

navigate()
method takes a second, optional argument: an object of extra settings for the journey. The
queryParams
field in that object is an ordinary set of keys and values that Angular will glue onto the address after the question mark.

1sortByName(): void {
2  this.router.navigate(['/samurai'], {
3    queryParams: { sort: 'name', order: 'asc' }
4  });
5}

After this call the address bar shows

/samurai?sort=name&order=asc
. Route matching did not change along the way - the router still picks the
samurai
route, exactly the same one it would pick without the notes, because query parameters take no part in matching. The only thing that changed is the address that a component reading
queryParamMap
will see.

By default every new navigation throws away the query parameters that were there before. If the user has set a sort order and some filters and you move them to the next screen, the notes are lost. You control this with the

queryParamsHandling
field, which takes three sensible values:
'preserve'
keeps the existing notes and ignores the ones you have just passed,
'merge'
combines the old with the new, and
'replace'
restores the default behaviour. I recommend
'merge'
as your first choice, @name, because
'preserve'
quietly bins the parameters handed to it in the very same call, and that can cost you an hour of hunting.

1goToSamuraiKeepingFilters(id: number): void {
2  this.router.navigate(['/samurai', id], {
3    queryParamsHandling: 'preserve'
4  });
5}

If the user was on

/samurai?sort=level
, then after this call they land on
/samurai/7?sort=level
- the warrior number changed, the note about sorting survived. What did not change is the method itself: it is still
navigate()
with an array of segments, with just one option added in the second argument.

navigateByUrl - the whole address as one string

Sometimes the address reaches you in one piece and there is nothing to build an array from. The classic case is an address remembered before the login screen, so that the user can be dropped back where they started once they sign in. Carving such a string into segments would be a waste of effort, so

Router
offers a second method:
navigateByUrl()
. It takes the entire address as a single string, query parameters and the fragment after the hash included.

1goToSavedUrl(): void {
2  this.router.navigateByUrl('/samurai/123?sort=level');
3}

Do not try the same thing with

navigate()
- the call
navigate('/samurai/123')
will not compile, because the first argument of that method has to be an array and TypeScript rejects a lone string. Do not go looking for
goTo()
or
redirect()
either: the
Router
service has neither, and the compiler will tell you plainly that no such property exists. The confusing one is
redirectTo
, which you do know from route configuration, but that is a field on an entry in the routes array, not a method on the service.

Day to day I recommend

navigate()
as your default: it joins segments safely, encodes special characters, and accepts options such as
queryParams
. Reach for
navigateByUrl()
when the address arrives from outside as finished text and taking it apart would be craft for craft's sake.

Remember this, @name:

ActivatedRoute
is the scroll you read and
Router
is the order to march that you issue - one glance at the gate is enough while you stand still, but when the road shifts under your feet, you need a runner.

Go to CodeWorlds