It goes without saying that the
angular/routerpackage is packed with a wealth of functionality. Rather than zeroing in on one narrow subject, we'll take a look at a handful of fascinating facts and characteristics of this package that may not be immediately obvious. These can range from comparisons (e.g.,relativevs.absoluteredirects) to subtle details (e.g., the hierarchy ofRouterOutlet; how the browser's URL actually gets updated).Familiarity with the fundamentals of Angular Router (such as route navigations and outlets) is a prerequisite for this article. Upon finishing, you'll have a firmer grasp of the potential this package holds.
Relative Versus Absolute Redirects
While constructing the route configuration array, the
redirectToproperty frequently comes into play. Although its function is fairly self-explanatory, there are a few noteworthy characteristics worth delving into.This property can point to either a relative or an absolute path. Before we highlight the differences between the two approaches, let's first establish the configuration we'll refer to:
const routes: Routes = [ { path: '', pathMatch: 'full', component: DefaultComponent }, { path: 'a/b', component: AComponent, // reachable from `DefaultComponent` children: [ { // Reached when `redirectTo: 'err-page'` (relative) is used path: 'err-page', component: BComponent, }, { path: '**', redirectTo: 'err-page' }, ], }, { // Reached when `redirectTo: '/err-page'` is used path: 'err-page', component: DComponent, } ]A working StackBlitz example can be accessed here.
With the configuration as currently set, using
redirectTo: 'err-page'(a relative path) means theBComponentwill be utilized. Should we switch that to/err-page, theDComponentwould be the one selected. As a general rule, one contrast betweenredirectTo: 'foo/bar'andredirectTo: '/foo/bar'is that with an absolute path, the process of locating the next configuration object begins at the root—that is, the topmost, most external array of routes.const routes: Routes = [ // **STARTS FROM HERE** { /* ... */ }, { /* ... */ children: [ /* ... */ { path: '**', redirectTo: '/err-page' }, ], }, { path: 'err-page', /* ... */ } ]On the other hand, with a relative path, the search initiates from the first route in the array where the redirect was triggered:
const routes: Routes = [ { /* ... */ }, { /* ... */ children: [ // **STARTS FROM HERE** /* ... */ { path: '**', redirectTo: 'err-page' }, ], }, { path: 'err-page', /* ... */ } ]Moreover, an additional powerful capability of absolute redirects is that they can accommodate named outlets:
{ path: 'a/b', component: AComponent, children: [ { path: '', component: BComponent, }, { path: 'c', outlet: 'c-outlet', component: CComponent, }, ], }, { path: 'd-route', redirectTo: '/a/b/(c-outlet:c)' }It's important to note that an absolute redirect can be performed just once in a given route transition.
The
pathproperty, which resides in the same configuration object asredirectTo, introduces a few more intriguing possibilities. This property can accept a simple string that defines a route path, or'**', which designates a wildcard route. Such a wildcard route matches virtually anything it's compared against. So, let's consider the options provided by a non-wildcard route.First, with a non-wildcard route, it's feasible to reuse the
query paramsand thepositional params(the params that follow the:nameOfParamsyntax) from the currently issued URL:const routes: Routes = [ { path: 'a/b', component: AComponent, children: [ { // Reached when `redirectTo: 'err-page'` (relative) is used path: 'err-page', component: BComponent, }, { path: 'c/:id', // foo=:foo - get the value of the `foo` query param that // exists in the URL that against this route // it works for relative paths as well: `err-page/:id?errored=true&foo=:foo` redirectTo: '/err-page/:id?errored=true&foo=:foo' }, ], }, { // Reached when `redirectTo: '/err-page'` is used path: 'err-page/:id', component: DComponent, } ]In the snippet above, we can see this pattern realized through:
?name=:foo– thefooquery param is pulled directly from the actual URLpath: 'a/:id',redirectTo: 'err-page/:id'– theidpositional param is sourced from thea/:idrouteHere's how a navigation to such a route would be initiated:
<button routerLink="a/b/c/123" [queryParams]="{ foo: 'foovalue' }">...</button>Additionally, when pairing a
non-wildcardpath with arelativeredirect, any extra URL segments get appended to theredirectTosegments:const routes: Routes = [ { path: 'a/b', component: AComponent, children: [ { path: 'err-page/test', component: BComponent, }, { // `redirectTo: '/err-page'` - would lead to errors path: 'c', redirectTo: 'err-page' }, ], }, // this could never be reached from `path: 'c'` { path: 'err-page/test', component: DComponent, } ]Keep in mind: This behavior is exclusive to
relativeredirects.So, accessing the route for
BComponentcan be done in this manner:<button routerLink="a/b/c/test">...</button>The situation can get even more complex—and fascinating—when we bring
matrix params(e.g.,;k1=v1;k2=v2) into the mix. As a quick aside,positional paramsare those explicitly outlined in route paths (e.g.,/:id), whilematrix paramsare captured together with their corresponding path. Behind the scenes, Angular relies on abstractions likeUrlSegmentGroupandUrlSegmentto implement these features. A peek at theUrlSegment's implementation shows these matrix params in action. With that context, let's dive into an example:const routes: Routes = [ { path: 'd/a/:id/e', component: DComponent, }, { // `redirectTo: '/d/a/:id/e'` would work as well path: 'a/:id', redirectTo: 'd/a/:id/e' }, ]If we kick off a navigation with
<button [routerLink]="['/a', { p1: 1 }, '1', { p2: 2, p3: 3 }]">...</button>the route for
DComponentgets triggered, ultimately producing the URL:.../d/a;p1=1/1;p2=2;p3=3/eFirst, note that
['a/path', { p1, p2, p3 }]is the syntax for attaching matrix params to a segment, linking them to the preceding path. Following the earlier lessons, we can then use the positional params located in the current route within theredirectTopath. The key takeaway is that any matrix params for a given segment are retained in the new path during navigation, provided they're referenced inredirectTo.Finally, it deserves mention that wildcard routes can only reapply
query params. Positional params aren't viable because reusing them requires a match in thepathproperty; since'**'is in effect, they can't be further utilized inredirectTo.Here's a StackBlitz demo illustrating the reuse of query params in a wildcard route.
Router.navigateVersusRouter.navigateByUrlEven though both methods serve the same purpose—to trigger a new navigation—there are notable distinctions. It's essential to understand upfront that Angular Router relies on a
UrlTreeto execute navigation. Think of aUrlTreeas the deserialized counterpart of a URL string.The
navigate()method constructs the necessaryUrlTreefor navigation based on the existingUrlTree. This can be somewhat tricky, as it sometimes requires specifying therelativeToroute:navigate(commandsArray, { relativeTo: ActivatedRouteInstance }). WhenrelativeToisn't provided, the rootActivatedRouteis assumed.In contrast,
navigateByUrl()builds a completely newUrlTree, independent of the current one.For hands-on experimentation, check out the examples in this StackBlitz demo.
How Does the Browser URL Get Set?
Internally, Angular Router leverages the native History API. For instance, navigating to a new route like
/user/:idtriggers a call tohistory.pushState. Likewise,history.replaceStateis invoked when navigating to the same path or when thereplaceUrloption is set totrue.The StackBlitz demo here showcases the behavioral outcomes achievable with the
replaceUrloption.
The
skipLocationChangeOptionThis option guarantees that the
Router's method in charge of updating the browser's URL—and thereby adding entries to the history stack—won't be invoked. Nonetheless, theRouter's internal state gets updated as expected (e.g., route params, query params, everything observable fromActivatedRoute).Check out this StackBlitz demo.
As demonstrated, due to this option being active,
/dwon't appear in the address bar at all. Still, the component for the/droute (DComponent) gets loaded regardless.
The hierarchy created by the
RouterOutletdirectiveAt the heart of Angular's routing mechanism sits the
RouterOutletdirective, which appears in templates asrouter-outlet. Without this directive, rendering routed content in the browser would be impossible. In practice, however, it's quite common to end up with nestedrouter-outlettags while building applications. Let's consider a route configuration like the following:// in order to be able to see the `BarComponent`'s view, we'd need to have 2 `router-outlet` // 1 in `app.component.html` -> needed to render `FooComponent` // 1 in `foo.component` -> needed to render `BarComponent` const routes = [ { path: 'foo', component: FooComponent, children: [ { path: 'bar/:id', component: BarComponent } ] } ];Now imagine injecting
ActivatedRouteinsideBarComponent. Have you ever stopped to think about why, when the URL isfoo/bar/123, theActivatedRouteinstance you receive is precisely the one tied to thebar/:idroute — exposing the rightparamsandqueryParams? This is yet another responsibility taken care of by theRouterOutletdirective. In this section, we'll dig into exactly how that works (spoiler: a custom injector is involved!).Let's start with a simpler scenario, using this route setup:
// app.module.ts const routes = [ { path: 'foo', component: FooComponent, } ]To actually see something rendered for
/foo, therouter-outlettag needs to appear inapp.component.html:<button routerLink="/foo">Go to /foo route</button> <router-outlet></router-outlet>This is where the interesting part begins. Let's look at the initial steps of initialization:
constructor( private parentContexts: ChildrenOutletContexts, private location: ViewContainerRef, private resolver: ComponentFactoryResolver, @Attribute('name') name: string, private changeDetector: ChangeDetectorRef) { // in case we're using named outlet, we provide the `name` property // as we can see, it defaults to `PRIMARY_OUTLET`(`primary`) this.name = name || PRIMARY_OUTLET; parentContexts.onChildOutletCreated(this.name, this); }Right away, we encounter something somewhat unusual:
ChildrenOutletContexts. Let's take a closer look at what this class represents:export class ChildrenOutletContexts { // contexts for child outlets, by name. private contexts = new Map<string, OutletContext>(); /** Called when a `RouterOutlet` directive is instantiated */ onChildOutletCreated(childName: string, outlet: RouterOutlet): void { const context = this.getOrCreateContext(childName); context.outlet = outlet; this.contexts.set(childName, context); } /* ... */ getOrCreateContext(childName: string): OutletContext { let context = this.getContext(childName); if (!context) { context = new OutletContext(); this.contexts.set(childName, context); } return context; } getContext(childName: string): OutletContext|null { return this.contexts.get(childName) || null; } } export class OutletContext { outlet: RouterOutlet|null = null; route: ActivatedRoute|null = null; resolver: ComponentFactoryResolver|null = null; children = new ChildrenOutletContexts(); attachRef: ComponentRef<any>|null = null; }When any
RouterDirectiveis instantiated, it immediately callsChildrenOutletContexts.onChildOutletCreated(). From there, it either reuses an existing context or, as in this case, creates a new one. We've just introduced the notion of a context, which is precisely captured by theOutletContextclass. One particularly noteworthy aspect is that a context includes achildrenproperty pointing back to aChildrenOutletContextsinstance. In effect, this means we can think of the whole setup as a tree made up of context objects — more accurately, a tree ofOutletContextinstances.You might be wondering now why the
ChildrenOutletContextsclass bothers to keep a map ofOutletContexts:private contexts = new Map<string, OutletContext>();Even if that question didn't cross your mind right away, it's certainly worth asking. To get an answer, recall that Angular also supports named outlets. What would the
contextMaplook like if we introduced some?const routes = [ { path: 'foo', component: FooComponent, }, { path: 'bar', component: BarComponent, outlet: 'named-bar' } ]<!-- app.component.html --> <button routerLink="/foo">Go to /foo route</button> <button [routerLink]="[{ outlets: { named-bar: bar } }]">Go to /bar route - named outlet</button> <router-outlet></router-outlet> <router-outlet name="named-bar"></router-outlet>In this case, the main
ChildrenOutletContextsinstance will have itsonChildOutletCreatedmethod invoked twice, and each invocation results in a freshOutletContext. So thecontextmap ends up as:{ primary: OutletContext, 'named-bar': OutletContext }If we borrow terminology from the world of trees, the entries in the
contextmap represent a single level of the tree, and each value leads one level deeper.To make this concept more concrete, take a look at this StackBlitz demo. By checking the console output after clicking the initial button, you'll see the hierarchy in action. This can also double as a debugging approach if your routes don't seem to behave as expected.
With a clearer picture of the
RouterOutlethierarchy in mind, we can now uncover the mechanism that scopesActivatedRouteappropriately to each route.Near the end of the file housing
RouterOutlet, there's something worth paying attention to:class OutletInjector implements Injector { constructor( private route: ActivatedRoute, private childContexts: ChildrenOutletContexts, private parent: Injector) {} get(token: any, notFoundValue?: any): any { if (token === ActivatedRoute) { return this.route; } if (token === ChildrenOutletContexts) { return this.childContexts; } return this.parent.get(token, notFoundValue); } }Inspecting how
RouterOutletgoes about rendering content on screen shows us exactly whereOutletInjectorfits in:const injector = new OutletInjector(activatedRoute, childContexts, this.location.injector); // this.location - `ViewContainerRef` this.activated = this.location.createComponent(factory, this.location.length, injector);This mechanism is what guarantees that the correct
ActivatedRouteis always delivered when requested. When a component asks forActivatedRoute, Angular walks up the injector tree looking for the first provider of that token. The scope itself comes into existence whenRouterOutletcreates a new view. As theOutletInjectorimplementation shows, whenever theActivatedRoutetoken is requested, it supplies the activatedRoute that was captured at the moment the injector was constructed.
Do you really need to unsubscribe from
ActivatedRoute's properties?The quick answer is no.
Here's how an
ActivatedRoutegets created:function createActivatedRoute(c: ActivatedRouteSnapshot) { return new ActivatedRoute( new BehaviorSubject(c.url), new BehaviorSubject(c.params), new BehaviorSubject(c.queryParams), new BehaviorSubject(c.fragment), new BehaviorSubject(c.data), c.outlet, c.component, c); }Suppose you have a configuration along these lines:
{ path: 'a/:id', component: AComponent, children: [ { path: 'b', component: BComponent, }, { path: 'c', component: CComponent, }, ] }and you navigate to a URL like
a/123/b.What you end up with is a tree of
ActivatedRouteinstances:APP | A | BEach time you trigger a navigation — say, via
router.navigateToUrl()— the router needs to move through several critical phases:
- apply redirects: handling redirects, pulling in lazy-loaded modules, and surfacing any
NoMatcherrors- recognize: building the tree of
ActivatedRouteSnapshotobjects- preactivation: comparing the newly built tree against the existing one; this stage also gathers
canActivateandcanDeactivateguards based on what differs- running guards
- create router state: the step where the
ActivatedRoutetree is assembled- activating the routes: the final piece where the
ActivatedRoutetree is put to useIt's also worth highlighting the contribution of
router-outletin this process.As described in the previous section, Angular relies on a
Mapstructure to keep tabs on allrouter-outletelements.Given our route configuration:
{ path: 'a/:id', component: AComponent, children: [ { path: 'b', component: BComponent, }, { path: 'c', component: CComponent, }, ] }the
RouterOutletcontextsMapwould look something like this (simplified):{ primary: { // Where `AComponent` resides [1] children: { // Here `AComponent`'s children reside [2] primary: { children: { /* ... */ } } } } }When a
RouterOutletis activated — meaning it's about to display something — itsactivateWithmethod is called. As we saw earlier, this is where theOutletInjectoris constructed, givingActivatedRoutes their scope:activateWith(activatedRoute: ActivatedRoute, resolver: ComponentFactoryResolver|null) { if (this.isActivated) { throw new Error('Cannot activate an already activated outlet'); } this._activatedRoute = activatedRoute; /* ... */ const injector = new OutletInjector(activatedRoute, childContexts, this.location.injector); this.activated = this.location.createComponent(factory, this.location.length, injector); }Remember that
this.activatedholds the routed component (such asAComponent), whilethis._activatedRoutestores theActivatedRouteassociated with that component.Now let's see what occurs when we navigate elsewhere and the current view gets torn down:
deactivateRouteAndOutlet( route: TreeNode<ActivatedRoute>, parentContexts: ChildrenOutletContexts): void { const context = parentContexts.getContext(route.value.outlet); if (context) { const children: {[outletName: string]: any} = nodeChildrenAsMap(route); // from this we can also deduce that a component requires an additional `router-outlet` in this template // if it is part of route config. object where there is also a `children`/`loadChildren` property // the `route`'s `children` can also refer the routes obtained after loading a lazy module const contexts = route.value.component ? context.children : parentContexts; // Deactivate children first forEach(children, (v: any, k: string) => this.deactivateRouteAndItsChildren(v, contexts)); if (context.outlet) { // Destroy the component context.outlet.deactivate(); // Destroy the contexts for all the outlets that were in the component context.children.onOutletDeactivated(); } } }where
RouterOutlet.deactivate()is implemented like this:deactivate(): void { if (this.activated) { const c = this.component; this.activated.destroy(); // Destroying the current component this.activated = null; // Nulling out the activated route - so no `complete` notification this._activatedRoute = null; this.deactivateEvents.emit(c); } }Observe the line
this._activatedRoute = null;— this is why unsubscribing fromActivatedRoute's observable properties is unnecessary. Those properties are backed byBehaviorSubjects, and as we know, anySubjecttype keeps a list of subscribers. A memory leak could occur if a subscriber never removes itself from that list (which it can do viasubscriber.unsubscribe()). However, once the object that owns everything — in this case, the subscriber list — is set to null, it becomes eligible for garbage collection, since nothing references it anymore. That means a subscriber that never unsubscribed can no longer be triggered.
The
paramsInheritanceStrategyoptionThis option lives within the
ExtraOptionsobject passed toRouterModule.forRoot([], extraOptions)and accepts two values:'emptyOnly', which is the default, or'always'. With'emptyOnly', theparamsanddataobjects are inherited from the parent route if the route in question (not necessarily the one currently activated) haspath: '', or if the parent route has no component attached.Take this route configuration as an example:
const routes: Routes = [ { path: "", pathMatch: "full", component: DefaultComponent }, { path: "a/:id", data: { one: 1 }, resolve: { two: "resolveTwo" }, // component: AComponent, children: [ { path: "", data: { three: 3 }, component: BComponent }, { path: "", data: { four: 4 }, resolve: { five: "resolveFive" }, component: CComponent, outlet: "named-c" } ] } ];Navigating to
/a/123activates bothchildrenroutes (since each haspath: ''), and both then inherit thedataandparamsfrom their parent:params: { id: 123, },data: { one: 1, two: valueOfResolveTwo }. If we were to uncomment thecomponent: AComponent,line, the outcome would stay the same — inheritance hinges on either a componentless parent route or the route's ownpathbeing''.You can observe these results and play around further in this StackBlitz demo.
Let's walk through a few more cases:
[ { path: 'a', data: { one: 1 }, children: [ { path: 'b', data: { two: 2 }, component: ComponentB } ] } ]After a navigation to
a/b,ComponentB'sActivatedRoute.databecomes{one: 1, two: 2}, since the parentActivatedRoutecorresponds to a route without a component.[ { path: 'a', component: ComponentA, data: { one: 1 }, children: [ { path: 'b', data: { two: 2 }, component: ComponentB } ], }, ]Here, navigating to
a/bgivesComponentBanActivatedRoute.dataof{ two: 2 }— neither the currentActivatedRouteis on apath: ''route, nor does the parentActivatedRoutebelong to a componentless route. Switching toparamsInheritanceStrategy: 'always'would yield{ one: 1, two: 2 }instead.And one final illustration:
[ { path: 'foo/:id', children: [ { path: 'a/:name', children: [ { path: 'b', component: ComponentB, children: [ { path: 'c', component: ComponentC } ] } ] } ] } ]When navigating to
foo/123/a/andrei/b/c,ComponentB'sActivatedRoutecarriesparamsequal to{ id: 123, name: 'andrei' }(its parent is componentless, and so is that parent's parent), whileComponentC'sActivatedRoutereportsparamsas{}— the route it sits on haspath: 'c', and its parentActivatedRoutedoes not belong to a componentless route.
The
queryParamsHandlingoptionThis option can be set on the
RouterLinkdirective or theRouterLinkWithRefdirective, and it accepts one of two values:'merge'or'preserve'.All the examples below are available in this StackBlitz demo.
There's also a handy trick connected to this feature: it lets you reuse the same view without reloading the component, but with a fresh set of
queryParams:<!-- assuming the current route has `k1='v1'` --> <!-- after clicking the button, the same component will be used(without being reloaded) --> <!-- but the `queryParams` this time will be those written below --> <button [queryParams]="{ k2: 'v2', k1: 'foo-value-refreshed' }" [routerLink]="[]">...</button>
Controlling when guards and resolvers execute
One of the standout qualities of
@angular/routeris the sheer number of customization options it exposes. Among them is therunGuardsAndResolversoption, which you can include in theRouteconfiguration object:export type RunGuardsAndResolvers = 'pathParamsChange'|'pathParamsOrQueryParamsChange'|'paramsChange'|'paramsOrQueryParamsChange'| 'always'|((from: ActivatedRouteSnapshot, to: ActivatedRouteSnapshot) => boolean);Rather than relying on a StackBlitz app this time, we'll draw on examples pulled from a handful of test cases. Here's the route configuration being used:
// runGuardsAndResolvers: RunGuardsAndResolvers = 'paramsChange' (the default value) [ { path: 'a', runGuardsAndResolvers, component: /* ... */, canActivate: ['guard'], resolve: {data: 'resolver'} }, ]with the note that
resolveris essentially a counter, bumped up each time its function runs.Below are some scenarios, along with the logic driving each test case:
router.navigateByUrl('/a'); const cmp = /* ... */; // the component associated with the `path: 'a'` route const recordedData: any[] = []; cmp.route.data.subscribe((data: any) => recordedData.push(data)); // the values will be of type: `{ data: counterValue }` runGuardsAndResolvers = `paramsChange` // run guards & resolvers when either `positional params` or `matrix params` change // since the first navigation already occurred, the resolver function was invoked once expect(recordedData).toEqual([{data: 0}]); // although it's the same URL, the matrix params are different, so the guards and resolvers will be invoked once again router.navigateByUrl('/a;p=1'); expect(recordedData).toEqual([{data: 0}, {data: 1}]); // same case the previous one router.navigateByUrl('/a;p=2'); expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]); router.navigateByUrl('/a;p=2?q=1'); // this time, nothing is changed, because only the `queryParams` have changed, but not the params // this would've worked if `runGuardsAndResolvers` was set to `paramsOrQueryParamsChange` // so, `paramsOrQueryParamsChange` = `paramsChange` | `queryParamsChange` expect(recordedData).toEqual([{data: 0}, {data: 1}, {data: 2}]);The
pathParamsChangevalue can feel a bit puzzling initially, but a few examples should clear things up:// let's presume the counter has been reset // run guards & resolvers when only the positional params change // under the hood its just comparing the URLs of 2 `ActivatedRouteSnapshot` nodes that have the same route config. object runGuardsAndResolvers = 'pathParamsChange' router.navigateByUrl('/a'); // `pathParamsChange` implies something like `a/1 !== a/2` // changing any optional(matrix) params will not result in running guards or resolvers router.navigateByUrl('/a;p=1'); expect(recordedData).toEqual([{data: 0}]); router.navigateByUrl('/a;p=2'); expect(recordedData).toEqual([{data: 0}]);Finally, there's
pathParamsOrQueryParamsChange, which behaves likepathParamsChangeabove but also triggers guards and resolvers whenqueryParamsshift:// let's presume the counter has been reset runGuardsAndResolvers = 'pathParamsOrQueryParamsChange' router.navigateByUrl('/a'); // changing matrix params will not result in running guards or resolvers router.navigateByUrl('/a;p=1'); expect(recordedData).toEqual([{data: 0}]); router.navigateByUrl('/a;p=2'); expect(recordedData).toEqual([{data: 0}]); // adding query params will re-run guards/resolvers router.navigateByUrl('/a;p=2?q=1'); expect(recordedData).toEqual([{data: 0}, {data: 1}]);
Final Thoughts
Throughout this piece, we've explored a wide range of the
@angular/routercapabilities that often go unnoticed. Hopefully, this has clarified any uncertainties you've encountered with this library and highlighted the depth of its flexibility.Appreciate your time!
Angular Router: Revealing some interesting facts and features
Master advanced Angular Router patterns. Learn to optimize redirects, manage outlets, and control guard execution for cleaner navigation.
