I'm Todd, I teach the world Angular through @UltimateAngular. Conference speaker and Developer Expert at Google.

Dynamic page titles in Angular 2 with router events

Updating page titles in AngularJS (1.x) was a little problematic and typically was done via a global $rootScope property that listened for route change events to fetch the current route and map across a static page title. In Angular (v2+), the solution is far easier as it provides a single API, however we can actually tie this API into route change events to dynamically update the page titles.

Title Service

In Angular, we can request the Title from platform-browser (we’re also going to import the router too):

import { Title } from [email protected]/platform-browser';
import { Router } from [email protected]/router';

Once imported, we can inject them both:

@Component({
  selector: 'app-root',
  templateUrl: `
    <div>
      Hello world!
    </div>
  `
})
export class AppComponent {
  constructor(private router: Router, private titleService: Title) {}
}

To use the titleService, we must check out the source:

export class Title {
  /**
   * Get the title of the current HTML document.
   * @returns {string}
   */
  getTitle(): string { return getDOM().getTitle(); }

  /**
   * Set the title of the current HTML document.
   * @param newTitle
   */
  setTitle(newTitle: string) { getDOM().setTitle(newTitle); }
}

So we have two methods, getTitle and setTitle, easy enough!

The Title class is currently experimental, so if it changes I’ll update this post.

To update a page title statically, we can simply call setTitle like so:

@Component({...})
export class AppComponent implements OnInit {
  constructor(private router: Router, private titleService: Title) {}
  ngOnInit() {
    this.titleService.setTitle('My awesome app');
  }
}

One thing I liked about ui-router in AngularJS was the ability to add a custom data: {} Object to each route, which could be inherited down the chain of router states:

// AngularJS 1.x + ui-router
.config(function ($stateProvider) {
  $stateProvider
    .state('about', {
      url: '/about',
      component: 'about',
      data: {
        title: 'About page'
      }
    });
});

In Angular we can do the exact same however we need to add some custom logic around route changes to get it working. First, assume the following routes in a pseudo-calendar application:

const routes: Routes = [{
  path: 'calendar',
  component: CalendarComponent,
  children: [
    { path: '', redirectTo: 'new', pathMatch: 'full' },
    { path: 'all', component: CalendarListComponent },
    { path: 'new', component: CalendarEventComponent },
    { path: ':id', component: CalendarEventComponent }
  ]
}];

Here we have a base path /calendar with the opportunity to hit three child URLs, /all to view all calendar entries as a list, /new to create a new calendar entry and a unique /:id which can accept unique hashes to correspond with user data on the backend. Now, we can add some page title information under a data Object:

const routes: Routes = [{
  path: 'calendar',
  component: CalendarComponent,
  children: [
    { path: '', redirectTo: 'new', pathMatch: 'full' },
    { path: 'all', component: CalendarListComponent, data: { title: 'My Calendar' } },
    { path: 'new', component: CalendarEventComponent, data: { title: 'New Calendar Entry' } },
    { path: ':id', component: CalendarEventComponent, data: { title: 'Calendar Entry' } }
  ]
}];

That’s it. Now back to our component!

Routing events

The Angular router is great for setting up basics, but it’s also extremely powerful in supporting routing events, through Observables.

Note: we’re using the AppComponent because it’s the root component, therefore will always be subscribing to all route changes.

To subscribe to the router’s events, we can do this:

ngOnInit() {
  this.router.events
    .subscribe((event) => {
      // example: NavigationStart, RoutesRecognized, NavigationEnd
      console.log(event);
    });
}

The way that we can check which events are the ones we need, ideally NavigationEnd, we can do this:

this.router.events
  .subscribe((event) => {
    if (event instanceof NavigationEnd) {
      console.log('NavigationEnd:', event);
    }
  });

This is a fine approach, but because the Angular router is reactive, we’ll implement more logic using RxJS, let’s import:

import 'rxjs/add/operator/filter';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/mergeMap';

Now we’ve added filter, map and mergeMap to our router Observable, we can filter out any events that aren’t NavigationEnd and continue the stream if so:

this.router.events
  .filter(event => event instanceof NavigationEnd)
  .subscribe((event) => {
    console.log('NavigationEnd:', event);
  });

Secondly, because we’ve injected the Router class, we can access the routerState:

this.router.events
  .filter(event => event instanceof NavigationEnd)
  .map(() => this.router.routerState.root)
  .subscribe((event) => {
    console.log('NavigationEnd:', event);
  });

However, as a perhaps better alternative to accessing the routerState.root directly, we can inject the ActivatedRoute into the class:

import { Router, NavigationEnd, ActivatedRoute } from [email protected]/router';

@Component({...})
export class AppComponent implements OnInit {
  constructor(
    private router: Router,
    private activatedRoute: ActivatedRoute,
    private titleService: Title
  ) {}
  ngOnInit() {
    // our code is in here
  }
}

So let’s rework that last example:

this.router.events
  .filter(event => event instanceof NavigationEnd)
  .map(() => this.activatedRoute)
  .subscribe((event) => {
    console.log('NavigationEnd:', event);
  });

By returning a new Object into our stream (this.activatedRoute) we essentially swap what we’re observing - so at this point we are only running the .map() should the filter() successfully return us the event type of NavigationEnd.

Now comes the interesting part, we’ll create a while loop to traverse over the state tree to find the last activated route, and then return it to the stream:

this.router.events
  .filter(event => event instanceof NavigationEnd)
  .map(() => this.activatedRoute)
  .map(route => {
    while (route.firstChild) route = route.firstChild;
    return route;
  })
  .subscribe((event) => {
    console.log('NavigationEnd:', event);
  });

Doing this allows us to essentially dive into the children property of the routes config to fetch the corresponding page title(s). After this, we want two more operators:

this.router.events
  .filter(event => event instanceof NavigationEnd)
  .map(() => this.activatedRoute)
  .map(route => {
    while (route.firstChild) route = route.firstChild;
    return route;
  })
  .filter(route => route.outlet === 'primary')
  .mergeMap(route => route.data)
  .subscribe((event) => {
    console.log('NavigationEnd:', event);
  });

Now our titleService just needs implementing:

.subscribe((event) => this.titleService.setTitle(event['title']));

Now we have a fully working piece of code that updates the page title based on route changes. You can check the full source below.

Final code

import 'rxjs/add/operator/filter';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/mergeMap';

import { Component, OnInit } from [email protected]/core';
import { Router, NavigationEnd, ActivatedRoute } from [email protected]/router';
import { Title } from [email protected]/platform-browser';

@Component({...})
export class AppComponent implements OnInit {
  constructor(
    private router: Router,
    private activatedRoute: ActivatedRoute,
    private titleService: Title
  ) {}
  ngOnInit() {
    this.router.events
      .filter(event => event instanceof NavigationEnd)
      .map(() => this.activatedRoute)
      .map(route => {
        while (route.firstChild) route = route.firstChild;
        return route;
      })
      .filter(route => route.outlet === 'primary')
      .mergeMap(route => route.data)
      .subscribe((event) => this.titleService.setTitle(event['title']));
  }
}
Todd Motto

I'm Todd, I teach the world Angular through @UltimateAngular. Conference speaker and Developer Expert at Google.