Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at InVision In Real Life (IRL) 2018 (Hollywood, CA) with: David Boon
Ben Nadel at InVision In Real Life (IRL) 2018 (Hollywood, CA) with: David Boon@evadnoob )

Collecting Route Params Across All Router Segments In Angular 6.0.7

By Ben Nadel on

One of the really cool things about the Angular Router is that it supports location paths that implement "matrix URL notation". This matrix URL notation creates strong cohesion between the route parameters and the route segments to which they belong. This cohesion is really nice from an ActivatedRoute perspective; but, it makes observing the route a bit more complicated (externally to the ActivatedRoute). As such, people often wonder how they can access route parameters in a global context. And, while this isn't necessarily the "Angular Way," it is certainly possible to aggregate all route parameters into a single collection.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

Due to the high-cohesion of the matrix URL notation, route parameters in an Angular application don't have to be uniquely named. In fact, every route parameter in your application could be called ":id". And, as long as you only have one parameter per ActivatedRoute, everything will work fine.

The flip-side to this cohesion is that parameter names don't have as much affordance as they might in a more traditional router. Meaning, from a human consumption standpoint, understanding the meaning of any given parameter may depend more on its context than on its name. For example, instead of seeing, ":userID", you may just see, ":id", and it's up to you to remember that you are in a "user feature" module.

PRO TIP: For a variety of reasons, not the least of which is code searchability, I recommend that you use parameter names with good affordance. It is better to make your code easier to read than it is to make it "technically" correct.

I bring this up as a precaution that if you try to aggregate all route parameters into a single collection, the chances are good (depending on your app structure) that two different route parameters may create a naming-collision when merged into a single object. That said, let's take a look at how that can be done in Angular 6.0.7.

The Angular Router parses the URL into a Tree of nodes. Thanks to matrix URL notation, each of these nodes has its own set of "params". And, because the Angular Router allows for secondary router outlets, each of these nodes points to a collection of child nodes (ie, it's not just a liner collection of route segments).

To aggregate all route params into a single collection, we can walk the URL Tree and inspect the ".params" property. To demonstrate this, I've created an Angular Service that encapsulates this logic and exposes a ".params" aggregate. And, because the Router State changes over time, I'm exposing that aggregate as an RxJS Observable:

  • // Import the core angular services.
  • import { ActivatedRouteSnapshot } from "@angular/router";
  • import { BehaviorSubject } from "rxjs";
  • import { Injectable } from "@angular/core";
  • import { Event as RouterEvent } from "@angular/router";
  • import { filter } from "rxjs/operators";
  • import { NavigationEnd } from "@angular/router";
  • import { Observable } from "rxjs";
  • import { Params } from "@angular/router";
  • import { pipe } from "rxjs";
  • import { Router } from "@angular/router";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Injectable({
  • providedIn: "root"
  • })
  • export class RouterParams {
  •  
  • public params: BehaviorSubject<Params>;
  • public paramsSnapshot: Params;
  •  
  • private router: Router;
  •  
  • // I initialize the router params service.
  • constructor( router: Router ) {
  •  
  • this.router = router;
  •  
  • this.paramsSnapshot = {};
  • this.params = new BehaviorSubject( this.paramsSnapshot );
  •  
  • // We will collection the params after every Router navigation event. However,
  • // we're going to defer param aggregation until after the NavigationEnd event.
  • // This should leave the Router in a predictable and steady state.
  • // --
  • // NOTE: Since the router events are already going to be triggering change-
  • // detection, we probably don't have to take any precautions about whether or
  • // not we subscribe to these events inside the Angular Zone.
  • this.router.events
  • .pipe(
  • filter(
  • ( event: RouterEvent ) : boolean => {
  •  
  • return( event instanceof NavigationEnd );
  •  
  • }
  • )
  • )
  • .subscribe(
  • ( event: NavigationEnd ) : void => {
  •  
  • var snapshot = this.router.routerState.snapshot.root;
  • var nextParams = this.collectParams( snapshot );
  •  
  • // A Router navigation event can occur for a variety of reasons, such
  • // as a change to the search-params. As such, we need to inspect the
  • // params to see if the structure actually changed with this
  • // navigation event. If not, we don't want to emit an event.
  • if ( this.paramsAreDifferent( this.paramsSnapshot, nextParams ) ) {
  •  
  • this.params.next( this.paramsSnapshot = nextParams );
  •  
  • }
  •  
  • }
  • )
  • ;
  •  
  • }
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  • // I collect the params from the given router snapshot tree.
  • // --
  • // CAUTION: All params are merged into a single object. This means that like-named
  • // params in different tree nodes will collide and overwrite each other.
  • private collectParams( root: ActivatedRouteSnapshot ) : Params {
  •  
  • var params: Params = {};
  •  
  • (function mergeParamsFromSnapshot( snapshot: ActivatedRouteSnapshot ) {
  •  
  • Object.assign( params, snapshot.params );
  •  
  • snapshot.children.forEach( mergeParamsFromSnapshot );
  •  
  • })( root );
  •  
  • return( params );
  •  
  • }
  •  
  •  
  • // I determine if the given param collections have a different [shallow] structure.
  • private paramsAreDifferent(
  • currentParams: Params,
  • nextParams: Params
  • ) : boolean {
  •  
  • var currentKeys = Object.keys( currentParams );
  • var nextKeys = Object.keys( nextParams );
  •  
  • // If the collection of keys in each set of params is different, then we know
  • // that we have two unique collections.
  • if ( currentKeys.length !== nextKeys.length ) {
  •  
  • return( true );
  •  
  • }
  •  
  • // If the collections of keys have the same length then we have to start
  • // comparing the individual KEYS and VALUES in each collection.
  • for ( var i = 0, length = currentKeys.length ; i < length ; i++ ) {
  •  
  • var key = currentKeys[ i ];
  •  
  • // Compare BOTH the KEY and the VALUE. While this looks like it is comparing
  • // the VALUE alone, it is implicitly comparing the KEY as well. If a key is
  • // defined in one collection but not in the other collection, one of the
  • // values will be read as "undefined". This "undefined" value implies that
  • // either the KEY or the VALUE was different.
  • if ( currentParams[ key ] !== nextParams[ key ] ) {
  •  
  • return( true );
  •  
  • }
  •  
  • }
  •  
  • // If we made it this far, there was nothing to indicate that the two param
  • // collections are different.
  • return( false );
  •  
  • }
  •  
  • }

As you can see, this service subscribes to the Angular Router "events" property and filters on the NavigationEnd event. This event should signify a change in the URL Tree structure which may indicate a change in the route params. For each NavigationEnd event, we then walk the Router State URL Tree and merge all the params into a single object. We then check to see if this new aggregation is structurally different from the previous aggregation. And, if so, we emit it as the next value on the "params" Observable.

To see this in action, I've created an Angular application that has a primary outlet and two secondary outlets. Each of the outlets has a list page and detail page. Each detail page path segment has a uniquely-named parameter:

  • // .... truncated module file.
  • var routes: Routes = [
  • {
  • path: "app",
  • children: [
  • {
  • path: "primary",
  • component: PrimaryViewComponent,
  • children: [
  • {
  • path: "",
  • pathMatch: "full",
  • component: PrimaryListViewComponent
  • },
  • {
  • path: "detail/:primaryID",
  • component: PrimaryDetailViewComponent
  • }
  • ]
  • },
  • {
  • outlet: "secondary",
  • path: "secondary",
  • component: SecondaryViewComponent,
  • children: [
  • {
  • path: "",
  • pathMatch: "full",
  • component: SecondaryListViewComponent
  • },
  • {
  • path: "detail/:secondaryID",
  • component: SecondaryDetailViewComponent
  • }
  • ]
  • },
  • {
  • outlet: "tertiary",
  • path: "tertiary",
  • component: TertiaryViewComponent,
  • children: [
  • {
  • path: "",
  • pathMatch: "full",
  • component: TertiaryListViewComponent
  • },
  • {
  • path: "detail/:tertiaryID",
  • component: TertiaryDetailViewComponent
  • }
  • ]
  • }
  • ]
  • },
  •  
  • // Redirect from the root to the "/app" prefix (this makes other features, like
  • // secondary outlets easier to implement later on).
  • {
  • path: "",
  • pathMatch: "full",
  • redirectTo: "app"
  • }
  • ];

The implementation details of these various View components is not very interesting. All that's valuable here is to see the various path parameter names. The only interesting View component is the App component. Inside the App component, we inject the RouterParams service (that we defined above) and then subscribe to the ".params" stream:

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  • import { Params } from "@angular/router";
  •  
  • // Import the application components and services.
  • import { RouterParams } from "./router-params";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Component({
  • selector: "my-app",
  • styleUrls: [ "./app.component.less" ],
  • template:
  • `
  • <div class="nav">
  • <a routerLink="/app" class="nav__item">Home</a>
  • <a routerLink="/app/primary" class="nav__item">Primary</a>
  •  
  • <a [routerLink]="[ '/app', { outlets: { secondary: 'secondary' } } ]" class="nav__item">Secondary</a>
  • <a [routerLink]="[ '/app', { outlets: { tertiary: 'tertiary' } } ]" class="nav__item">Tertiary</a>
  • </div>
  •  
  • <h1>
  • Collecting Route Params Across All Router Segments In Angular 6.0.7
  • </h1>
  •  
  • <p class="params">
  • <strong>All Params:</strong> {{ params | json }}
  • </p>
  •  
  • <router-outlet></router-outlet>
  • <router-outlet name="secondary"></router-outlet>
  • <router-outlet name="tertiary"></router-outlet>
  • `
  • })
  • export class AppComponent {
  •  
  • public params: Params;
  •  
  • // I initialize the app-component.
  • constructor( routerParams: RouterParams ) {
  •  
  • this.params = {};
  •  
  • // The RouteParams service aggregates the params across all segments. When the
  • // router state changes, the "params" stream is updated with the new values.
  • routerParams.params.subscribe(
  • ( params: Params ) : void => {
  •  
  • this.params = params;
  •  
  • console.log( "Router Params have changed:" );
  • console.table( params );
  •  
  • }
  • );
  •  
  • }
  •  
  • }

As you can see, within our App component, we subscribe to the RouterParams service. And, as it emits events, we are grabbing the emitted params aggregation and rendering it on the page using the JSON Pipe.

If we open the application and click through to the various sections, we get the following browser output:


 
 
 

 
 Collect all Angular Router path segments into a single global collection (as an RxJS Observable). 
 
 
 

As you can see, the parameters from each route segment were picked into the aggregation that was emitted by the RouterParams service.

I'll reiterate that this isn't necessarily the "Angular Way" of consuming the Router path. But, looking at how this is done can help you better understand the structure of the Router URL Tree. And, of course, sometimes you actually do need access route parameters outside of the View Components. In such a case, hopefully this will help.



Looking For A New Job?

Ooops, there are no jobs. Post one now for only $29 and own this real estate!

100% of job board revenue is donated to Kiva. Loans that change livesFind out more »

Reader Comments

IMPORTANT UPDATE: On Twitter, Danny Blue let me know that the latest versions of the Router actually have a property that helps enable (at least) some of this functionality:

https://twitter.com/dee_bloo/status/1015232533410217984

It's the paramsInheritanceStrategy Router option. Now, I haven't dug into that myself -- I will this weekend. So, I am not exactly sure where the overlap on functionality is. The fact that it's called an "inheritance" strategy makes me think that it won't take secondary router outlets into account; but I'll know more when I look at the docs and code.

Reply to this Comment

@All,

I just came across an interesting "bug", or maybe "caveat". The Close link in the PRIMARY outlet only works when there is a secondary outlet opened as well. In that case, the PRIMARY outlet will close just fine. But, if the PRIMARY outlet is the only router-outlet being rendered, then the Close link doesn't do anything.

I am not sure if this is because it's inside a pathless-component (I suspect so). Or, if there is something more buggy going on there.

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.