Monday, 13 November, 2017 UTC


Summary

Updating the HTMLTitleElement is easy with Angular’s Title service. It is pretty common for each route in a SPA to have a different title. This is often done manually in the ngOnInit lifecycle of the route’s component. However, in this post we will do it in a declarative way using the power of the @ngrx/router-store with a custom RouterStateSerializer and @ngrx/effects.
The concept is as follows:
  • Have a title property in a route definition’s data.
  • Use @ngrx/store to keep track of the application state.
  • Use @ngrx/router-store with a custom RouterStateSerializer to add the desired title to the application state.
  • Create an updateTitle effect using @ngrx/effects to update the HTMLTitleElement every time the route changes.
Project Setup
For a quick and easy setup, we will be using the @angular/cli.
# Install @angular-cli if you don't already have it npm install @angular/cli -g # Create the example with routing ng new title-updater --routing
Defining Some Routes
Create a couple components:
ng generate component gators ng generate component crocs
And define their routes:
title-updater/src/app/app-routing.module.ts
import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { GatorsComponent } from './gators/gators.component'; import { CrocsComponent } from './crocs/crocs.component'; const routes: Routes = [ { path: 'gators', component: GatorsComponent, data: { title: 'Alligators'} }, { path: 'crocs', component: CrocsComponent, data: { title: 'Crocodiles'} } ]; @NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule] }) export class AppRoutingModule { }
Notice the title property in each route definition, it will be used to update the HTMLTitleElement.
Add State Management
@ngrx is a great library to manage application state. For this example application we will use @ngrx/router-store to serialize the router into the @ngrx/store so we can listen for route changes and update the title accordingly.
We will be using @ngrx > 4.0 to leverage the new RouterStateSerializer
Install:
npm install @ngrx/store @ngrx/router-store --save
Create a custom RouterStateSerializer to add the desired title to the state:
title-updater/src/app/shared/utils.ts
import { RouterStateSerializer } from '@ngrx/router-store'; import { RouterStateSnapshot } from '@angular/router'; export interface RouterStateTitle { title: string; } export class CustomRouterStateSerializer implements RouterStateSerializer<RouterStateTitle> { serialize(routerState: RouterStateSnapshot): RouterStateTitle { let childRoute = routerState.root; while (childRoute.firstChild) { childRoute = childRoute.firstChild; } // Use the most specific title const title = childRoute.data['title']; return { title }; } }
Define the router reducer:
title-updater/src/app/reducers/index.ts
import * as fromRouter from '@ngrx/router-store'; import { RouterStateTitle } from '../shared/utils'; import { createFeatureSelector } from '@ngrx/store'; export interface State { router: fromRouter.RouterReducerState<RouterStateTitle>; } export const reducers = { router: fromRouter.routerReducer }; // While we won't be using this in this post, // selectors provide an easy way to access pieces of store using store.select(SELECTOR) to return an Observable of // that state subset and only emit a new value if it changes. export const getRouterState = createFeatureSelector<fromRouter.RouterReducerState<RouterStateTitle>>('router');
Every time the @ngrx/store dispatches an action (router navigation actions are sent by the StoreRouterConnectingModule), a reducer needs to handle that action and update the state accordingly. Above we define our application state to have a router property and to keep the serialized router state there using the CustomRouterStateSerializer.
One last step is needed to hook it all up:
title-updater/src/app/app.module.ts
import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store'; import { StoreModule } from '@ngrx/store'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { CrocsComponent } from './crocs/crocs.component'; import { GatorsComponent } from './gators/gators.component'; import { reducers } from './reducers/index'; import { CustomRouterStateSerializer } from './shared/utils'; @NgModule({ declarations: [ AppComponent, CrocsComponent, GatorsComponent ], imports: [ BrowserModule, AppRoutingModule, StoreModule.forRoot(reducers), StoreRouterConnectingModule ], providers: [ /** * The `RouterStateSnapshot` provided by the `Router` is a large complex structure. * A custom RouterStateSerializer is used to parse the `RouterStateSnapshot` provided * by `@ngrx/router-store` to include only the desired pieces of the snapshot, the title. */ { provide: RouterStateSerializer, useClass: CustomRouterStateSerializer } ], bootstrap: [AppComponent] }) export class AppModule { }
Sprinkle in the Magic @ngrx/effect
Now when we switch routes, our @ngrx/store will have the title we want. To update the title all we have to do now is listen for ROUTER_NAVIGATION actions and use the title on the state. We can do this with @ngrx/effects.
Install:
npm install @ngrx/effects --save
Create the effect:
title-updater/src/app/effects/title-updater.ts
import { Title } from '@angular/platform-browser'; import { Actions, Effect } from '@ngrx/effects'; import { ROUTER_NAVIGATION, RouterNavigationAction } from '@ngrx/router-store'; import 'rxjs/add/operator/do'; import { RouterStateTitle } from '../shared/utils'; @Injectable() export class TitleUpdaterEffects { @Effect({ dispatch: false }) updateTitle$ = this.actions .ofType(ROUTER_NAVIGATION) .do((action: RouterNavigationAction<RouterStateTitle>) => { this.titleService.setTitle(action.payload.routerState.title); }); constructor(private actions: Actions, private titleService: Title) {} }
Finally, hookup the updateTitle effect by importing it with EffectsModule.forRoot, this will start listening for the effect when the module is created by subscribing to all @Effect()s:
title-updater/src/app/app.module.ts
import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store'; import { StoreModule } from '@ngrx/store'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { CrocsComponent } from './crocs/crocs.component'; import { GatorsComponent } from './gators/gators.component'; import { reducers } from './reducers/index'; import { CustomRouterStateSerializer } from './shared/utils'; import { EffectsModule } from '@ngrx/effects'; import { TitleUpdaterEffects } from './effects/title-updater'; @NgModule({ declarations: [ AppComponent, CrocsComponent, GatorsComponent ], imports: [ BrowserModule, AppRoutingModule, StoreModule.forRoot(reducers), StoreRouterConnectingModule, EffectsModule.forRoot([TitleUpdaterEffects]) ], providers: [ /** * The `RouterStateSnapshot` provided by the `Router` is a large complex structure. * A custom RouterStateSerializer is used to parse the `RouterStateSnapshot` provided * by `@ngrx/router-store` to include only the desired pieces of the snapshot, the title. */ { provide: RouterStateSerializer, useClass: CustomRouterStateSerializer } ], bootstrap: [AppComponent] }) export class AppModule { }
And that’s it! You can now define titles in route definitions and they will automatically be updated when the route changes!
Going Further, from Static to Dynamic ⚡️
Static titles are great for most use cases, but what if you wanted to welcome a user by name or display a notification count as well? We can modify the title property in route data to be a function that accepts a context.
Here is a potential example if notificationCount was on the store:
title-updater/src/app/app-routing.module.ts
import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { GatorsComponent } from './gators/gators.component'; import { CrocsComponent } from './crocs/crocs.component'; import { InboxComponent } from './inbox/inbox.component'; const routes: Routes = [ { path: 'gators', component: GatorsComponent, data: { title: () => 'Alligators' } }, { path: 'crocs', component: CrocsComponent, data: { title: () => 'Crocodiles' } }, { path: 'inbox', component: InboxComponent, data: { // A dynamic title that shows the current notification count! title: (ctx) => { let t = 'Inbox'; if(ctx.notificationCount > 0) { t += ` (${ctx.notificationCount})`; } return t; } } } ]; @NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule] }) export class AppRoutingModule { }
title-updater/src/app/effects/title-updater.ts
import { Title } from '@angular/platform-browser'; import { Actions, Effect } from '@ngrx/effects'; import { ROUTER_NAVIGATION, RouterNavigationAction } from '@ngrx/router-store'; import { Store } from '@ngrx/store'; import 'rxjs/add/operator/combineLatest'; import { getNotificationCount } from '../selectors.ts'; import { RouterStateTitle } from '../shared/utils'; @Injectable() export class TitleUpdaterEffects { // Update title every time route or context changes, pulling the notificationCount from the store. @Effect({ dispatch: false }) updateTitle$ = this.actions .ofType(ROUTER_NAVIGATION) .combineLatest(this.store.select(getNotificationCount), (action: RouterNavigationAction<RouterStateTitle>, notificationCount: number) => { // The context we will make available for the title functions to use as they please. const ctx = { notificationCount }; this.titleService.setTitle(action.payload.routerState.title(ctx)); }); constructor(private actions: Actions, private store: Store, private titleService: Title) {} }
Now when the Inbox route is loaded, the user can see their notification count that is updated real-time as well! 💌
🚀 Continue to experiment and explore custom RouterStateSerializers and @ngrx!