Your trail is already cut, @name. The routes are written out, parameters are read off the scroll, sentries stand at the gates, and the dojo has rooms inside rooms. And yet the journey is still uncomfortable - in several ways at once.
You click "Armory" in the navigation and for half a second nothing happens. The screen just sits there, because the browser is fetching the armory's bundle of code and a resolver is waiting on data. The user has no idea whether the click even registered, so they click again. On top of that you have twelve browser tabs open and every one of them carries the same name, because you set the page title once, in
index.html. And finally the war council: the panel with the conversation should sit off to the side and not go dark every time the main view travels from the dojo to the armory and back.In the Academy no march happens in silence. When the column sets out, the post at the gate beats a drum; when it arrives, the destination gate answers with a beat of its own. Scouts leave ahead of the main body so that supplies are already waiting at the next camp. Above every gate hangs a cartouche with the name of the place, and beside the main gate there is often a small side gate through which you enter the council without leaving the courtyard. In this lesson you will add exactly those four things to your trail.
Let us start with the silence during a transition. The
Router service, which in the lesson on path parameters gave you a way to issue the order to march, has a passive side as well: the events field. It is a stream - the same kind of stream you saw when reading parameters - so you sign up for it with the subscribe() method, and Angular calls your function on every new report. The difference is that this time you do not receive a map of parameters but an object describing a stage of the journey, and every stage has its own class.Two classes are enough to build a loading bar. Angular sends
NavigationStart the moment navigation begins - right after a link is clicked or navigate() is called. NavigationEnd arrives at the very end, once the route has been matched, the sentries have let the traveller through, the data has been fetched and the component has landed in the outlet. Memorise those names to the letter: there is nothing in the @angular/router package called RouteStart or NavigationBegin. I checked it with the compiler - importing either name ends with an error about a missing export and the application will not even build.To recognise which object has just arrived we will use the
instanceof operator. It is a plain JavaScript operator: it asks whether the object on the left is an instance of the class on the right, and answers true or false. I set the subscription up in the constructor rather than in ngOnInit, because the very first navigation starts the instant the application boots and I want to hear that drum too.1import { Component, inject } from '@angular/core';
2import { Router, NavigationStart, NavigationEnd } from '@angular/router';
3
4@Component({
5 selector: 'app-root',
6 standalone: true,
7 template: '<p>Academy</p>'
8})
9export class AppComponent {
10 private router = inject(Router);
11 isLoading = false;
12
13 constructor() {
14 this.router.events.subscribe(event => {
15 if (event instanceof NavigationStart) {
16 this.isLoading = true;
17 }
18 if (event instanceof NavigationEnd) {
19 this.isLoading = false;
20 }
21 });
22 }
23}The
isLoading field now always agrees with the state of the journey: true while it lasts, false once it is over. Read the inside of that subscription line by line, because that is exactly the order it sits in the file: first the line that opens the subscription with a function taking the event, then the condition that lights the bar, then the condition that puts it out, and at the end the brace that closes the whole call. Notice what this change did not touch - the route configuration looks the same, the destination components know nothing about the bar, the sentries work unchanged. The subscription only listens in on the reports and blocks nothing: if you deleted it, navigation would run identically, just without telling the user anything.Between the drum and the answering gate Angular sends a few more reports, and it is worth knowing they are there. I ran the router and wrote the stream down: after
NavigationStart comes RoutesRecognized (the router now knows which route matches), then GuardsCheckStart and GuardsCheckEnd - those are your sentries from the lesson on guards, with reports about component activation in between - then ResolveStart and ResolveEnd, the work of the resolver from the previous lesson, and only at the very end NavigationEnd. The entire life of a route, which you have been learning in pieces throughout this module, flows through this one field.A logical flag on its own is not enough - the user has to see something. In the template of the root component I show the bar conditionally, with the
@if syntax from the lesson on components, and underneath it I leave the main outlet exactly where it stood.1@if (isLoading) {
2 <div class="loader">Messenger on the way...</div>
3}
4
5<router-outlet></router-outlet>From now on every transition between routes lights that message up and puts it out once the destination component is on screen. The place where rendering happens did not change:
<router-outlet> is still the same gate in which the active route's component appears, and the bar is only scaffolding standing next to it. Take a good look at the shape of that tag while it is in front of you, because you will be assembling it from pieces soon enough: an opening angle bracket, the tag name router-outlet, a closing angle bracket, and at the end the closing tag. isLoading is an ordinary class field and Angular refreshes the view by itself when it changes - in the module about smoke signals you will turn it into a signal and see why that is the better idea.There is a trap in this code and it is better to fall into it now than in production. I checked it on a running router: when a sentry turns the user back from the gate, Angular does not send
NavigationEnd. What arrives instead is NavigationCancel, and the address in the bar stays where it was. Your condition never runs, isLoading stays true, and the loading bar hangs on the screen until the end of the session. NavigationError, the report of a failure during the journey, ends the same way. So put the bar out on all three reports at once.1// same constructor, two names added to the import from @angular/router
2this.router.events.subscribe(event => {
3 if (event instanceof NavigationStart) {
4 this.isLoading = true;
5 }
6 if (event instanceof NavigationEnd
7 || event instanceof NavigationCancel
8 || event instanceof NavigationError) {
9 this.isLoading = false;
10 }
11});Now every journey ends with the bar going out, whether it reached its destination, was turned back by a sentry, or tripped on an error. The condition that lights the bar did not change by a single character - there is still exactly one start event and it is still called
NavigationStart. I recommend this version as your default, @name: the variant with NavigationEnd alone looks tidier in a lesson, but in a real application with sentries at the gates it freezes the bar the first time somebody is refused entry.The second discomfort is waiting for the bundle of code. In the module about clans you set up lazy loading: a route with
loadComponent or loadChildren does not go into the application's first bundle but travels separately, once the user actually goes there. Startup is instant because of it, but the cost does not vanish - it moves to the first click, and on a weak connection that is a few seconds of staring at the bar you have just built.You can have your cake and eat it: let the bundles arrive in the background once the application is up and nothing is happening. That is the job of a preloading strategy. Angular ships two of them:
NoPreloading, meaning fetch nothing in advance - and that is the default behaviour - and PreloadAllModules, meaning fetch every lazy route in the background. You hand the strategy to the router when you register it, with the withPreloading() function. This is one of the so-called router features, extra settings that provideRouter() accepts as further arguments after the array of routes. Read that call as one sentence: the name withPreloading, an opening parenthesis, the strategy name inside it, a closing parenthesis.1// app.config.ts
2import { ApplicationConfig } from '@angular/core';
3import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
4import { routes } from './app.routes';
5
6export const appConfig: ApplicationConfig = {
7 providers: [
8 provideRouter(routes, withPreloading(PreloadAllModules))
9 ]
10};Notice that what goes into
withPreloading() is the class name itself, with no new and no parentheses after it - Angular creates the instance of the strategy on its own. And now the important part, namely what did not change here: the array of routes is untouched, the entries with loadComponent are still lazy, and the application's first bundle did not grow by a single byte. The only thing that changed is the moment of the fetch.I checked this on a running router with one lazy route. Under the default strategy the code of that route was not fetched even once until I went there myself. After turning
PreloadAllModules on, the same code fetched itself right after the first NavigationEnd, even though nobody visited that route - and a later visit did not fetch it a second time, because it was already waiting in memory. That is where the whole benefit comes from: a transition that used to take as long as downloading a bundle becomes instant.When should you not do this? When the application has a heavy admin panel that an ordinary user will never open,
PreloadAllModules drags it down to everybody and wastes their bandwidth. For an application the size of our Academy I recommend it without hesitation, because it costs one call; on larger projects you write your own strategy on top of the PreloadingStrategy interface and preload only the routes you choose.The third discomfort is the war council. You want a side panel with its own route sitting next to the main view, you want a change of the main view not to put it out, and you want a page refresh not to close it. Which means the panel's state has to live in the address, exactly like the state of the main view.
Up to now every template of yours had a single
<router-outlet>, though nobody ever said there had to be only one. That default outlet has a name - primary - you simply never type it. Next to it you are allowed to put a second one carrying a name attribute, and in the route entry add an outlet field with the same name. The route then says: render me in that side gate, not in the main one. Let us start with the routes, because that is where the decision is made.1// app.routes.ts
2import { Routes } from '@angular/router';
3import { DojoComponent } from './dojo/dojo.component';
4import { ChatComponent } from './chat/chat.component';
5
6export const routes: Routes = [
7 { path: 'dojo', component: DojoComponent },
8 { path: 'chat', component: ChatComponent, outlet: 'sidebar' }
9];The entry with
outlet: 'sidebar' is not a child of any route - it is a separate trail running parallel to the main one. Nothing about matching the address /dojo changes because of it: when you go there the router still picks DojoComponent and puts it in the main gate, and the side gate stays empty until somebody opens it. Now the template. The main outlet stays as it was, next to it I add a second one and give it exactly the name I wrote in the route.1<main>
2 <router-outlet></router-outlet>
3</main>
4
5<aside>
6 <router-outlet name="sidebar"></router-outlet>
7</aside>Two tags, one difference: the
name attribute with a value in quotes. The one without the attribute is primary, the one with it serves routes marked as sidebar. The order of the pieces is always the same and is worth fixing in memory: the opening of the tag together with its name, then the attribute name with an equals sign, then the value in quotes, and at the end the part that closes the whole thing. I checked what happens when that last part is missing, because it is not obvious: Angular reports no error at all, it simply treats the rest of the template as the content of the outlet and inserts the route's component after it, which puts the elements on the screen in reverse order. The shorthand with a slash works too, but the full pair of tags is the basic form in Angular and I am sticking with it.What remains is the link that opens that side gate. Instead of a plain path you give
routerLink an object with an outlets field, and inside it a map: the name of the side gate and an array of the segments that should show up in it. Remember to have the RouterLink directive in the component's imports array, exactly as with ordinary links. I checked what happens without it: Angular refuses to bind the attribute, the anchor comes out with no address at all, and the only trace is a message in the console about routerLink not being a known property.1<a [routerLink]="[{ outlets: { sidebar: ['chat'] } }]">Call the council</a>I checked what the address looks like after that click, because the syntax is anything but self-explanatory: standing on
/dojo you get /dojo(sidebar:chat). The named side gate lands in parentheses after the main path, which is why it survives a page refresh and can be sent to somebody as a link. The most important part is the one you cannot see: the main gate did not even flinch - DojoComponent was on the screen and stays on the screen, only the panel's content was added. To close the side gate you navigate to it with null instead of an array, that is { outlets: { sidebar: null } }, and the address goes back to a clean /dojo.The fourth discomfort is the easiest one to repair. The page title - the one visible on the browser tab, in the history and in a screen reader - is in an ordinary project set once in
index.html and never changes again. The router can look after it for you: a route entry accepts a title field, and once navigation finishes Angular writes that value into the title of the document.Three other names come to mind in this spot and all three are wrong.
pageTitle is a habit from other libraries, unknown in Angular. head brings the section of an HTML document to mind, but a route entry has never heard of anything like that. name sounds the most natural of the lot and that is exactly what makes it dangerous, because a route has no field of that name. I compiled each of those three attempts and each ends with the same message from TypeScript: no such property exists on the Route type. There is exactly one correct field and it is called title.1// app.routes.ts, imports as above
2export const routes: Routes = [
3 { path: 'dojo', component: DojoComponent, title: 'Dojo - Samurai Academy' },
4 { path: 'armory', component: ArmoryComponent, title: 'Armory - Samurai Academy' }
5];After entering
/dojo the browser tab is now called "Dojo - Samurai Academy" - I ran this and the title of the document really is swapped on every transition. What does not change along with it: the view itself. The title field renders nothing, adds no heading to the template, and touches the tab bar and nothing else. The heading on the page you still have to write yourself.A static string is enough for screens there is only one of. It goes worse with a warrior's profile: behind the route
samurai/:id hide as many pages as you have samurai, and the title "Samurai profile" tells none of them apart. That is why title accepts not only text but a function as well. Angular calls it on every navigation and passes it the snapshot of the route - the same object from which you read paramMap in the lesson on parameters. Whatever string the function returns becomes the title.1{
2 path: 'samurai/:id',
3 component: SamuraiDetailComponent,
4 title: (route) => 'Samurai #' + (route.paramMap.get('id') ?? '')
5}Entering
/samurai/7 gives the title "Samurai #7" - I ran that one too and that is exactly what happens. You know the ?? operator from reading parameters: it protects you from the case where the parameter was missing and get() returned an empty value. What did not change is the way the parameter is read - the same paramMap, the same get() method, only called in the route configuration instead of in a component. You can replace the plus with a template string in backticks and the result will be identical; with a title this short I recommend the plus, because it does not make the reader hunt for braces.One thing is left to close, and you have just seen it from the side of the address. If the address can carry the state of a side gate, then it can certainly carry a small note of the "open the profile straight on the skills tab" kind. Notes like that are query parameters, and when navigating from code you point them out with the
queryParams field in the second argument of the navigate() method. The first argument stays as it was: an array of segments in which you give the warrior's number after the name of the route.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(5)">View warrior</button>'
8})
9export class SamuraiListComponent {
10 private router = inject(Router);
11
12 goToSamurai(id: number): void {
13 this.router.navigate(['/samurai', id], {
14 queryParams: { tab: 'skills' }
15 });
16 }
17}Clicking the button takes you to the address
/samurai/5?tab=skills - the segment with the number after the slash, the note after the question mark. The route matches exactly as it would without the note, because query parameters take no part in matching, and the profile component will read the tab when it needs it. There is one more detail here worth understanding after the lesson on guards: navigate() returns a promise, and inside it a boolean. True means the navigation went through, false means it was turned back. I checked both variants, and they differ more than you might expect. A sentry returning false gives you false, the address in the bar stays as it was, and only NavigationCancel lands in the stream. A sentry returning a UrlTree - the variant recommended in the lesson on sentries - behaves differently: the promise resolves to true, because the redirect succeeded, the address changes to the target, and the stream shows NavigationCancel first and NavigationEnd for the new route right after it. If you do anything else after navigating, clear a form or show a message for instance, it is worth glancing at that value through await or then().At the end of the module let us lay the road out as a single sequence, because you have been assembling it piece by piece across five lessons. Everything starts from the
Routes array: that is where you define the pairs of path and component, and along with them the parameters, the children, the guards, the resolvers and the titles. The array on its own does nothing - it is only a map lying on the table. Step two is registering that map in the application through provideRouter(routes) in the configuration file app.config.ts - only now does the router know that any trails exist at all, and this is where you add features such as withPreloading(). Step three belongs to the user: they click a link with routerLink or type the address in the browser bar. You can take that same step for them from code by calling one of two methods on the injected service: navigate(), which glues the route together out of an array of segments, or navigateByUrl() if you already have the whole address in one piece of text - the whole line is then this, a dot and the name of the service, a dot and the name of the method, and inside the parentheses the address in quotes. Step four belongs to the router: it matches the address against an entry in the array, walks it past the guards and the resolvers, and then renders the component in <router-outlet>. Whatever you add to routing later on, you add it to one of those four steps.Remember, @name: the trail does not have to be silent - it beats drums at every post, sends scouts for supplies before the column moves, and writes the name of the place on the cartouche above the gate, and your task is to listen to those reports.