Declarative Title Updater with Angular and ngrx

Mark P. Kennedy

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!

  Tweet It
✖ Clear

🕵 Search Results

🔎 Searching...