Collecting Route Params Across All Router Segments In Angular 6.0.7
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:
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.
Want to use code from this post? Check out the license.
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.@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 theClose
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.
Hi , I used your solution and it work very good. Now, I tried to use the "paramsInheritanceStrategy in allways" , but it does not work for me. I am using angular 6+ .Do you know how used that for obtain the same result like your solution?
Other question: What is the performance of your solution? It is heavy or despreatiable?
Thanks very much, great post.
Note: It took me some hours in internet, but your solution is the only that I could found that working well...
@D,
Interesting. I can't immediately think of a reason why the param inheritance strategy would change the way this works. Ultimately, all I'm doing is traversing the router state snapshot and merging all the
params
objects together. So, if oneparams
object is inheriting keys from another one, they should just naturally overwrite each other (as you would expect). I'll have to play around with this.As far as performance, I wouldn't worry -- it only does it on
NavigationEnd
events. Which means, it's only performing "work" when the user navigates to a new route. And, the size of the active router tree is naturally very limited by the scope of the application. I would not worry about performance on this at all.too much logic for too little value, just use angular the right way!
https://medium.com/@eng.ohadb/how-to-get-route-path-parameters-in-an-angular-service-1965afe1470e
@O,
Can you expand on what you mean by "user Angular the right way"? I'm looking at your code and it seems very much like my code. The major difference being that I am further wrapping the change inside a
BehaviorSubject
that checks to see if any of the params actually changes (as opposed to just publishing a params event any time the route changes).That said, I was basing my approach on the assumption that the
params
Object is actually rebuilt on each route navigation. But, maybe that assumption is incorrect (I can't remember if I actually ever tested it). It is something I should get some clarity on.