Note: This post is part of an eleven-part series on Angular development. You can find the series overview here.

Overview

Angular provides a powerful built in router to emulate traditional web page navigation and display new components when accessing various URLs within the application.  However, there are times that the default functionality can get in the way or cause unexpected results in the application.  There are various ways to work around some of this functionality, but wouldn’t it be great if we could have some control over the router to make some simple decisions that can greatly improve the functionality of our application without overhead?  Let’s look at the RouteReuseStrategy class and find out!  

To follow along with the articles there a two repositories created: a starting point and final solution.

RouteReuseStrategy

Angular provides an abstract class called RouteReuseStrategy that controls how and when components are created, saved, and destroyed during routing.  By default, Angular uses the DefaultRouteReuseStrategy class below: 

export class DefaultRouteReuseStrategy implements RouteReuseStrategy {
    shouldDetach(route: ActivatedRouteSnapshot): boolean { return false; }

    store(route: ActivatedRouteSnapshot, detachedTree: DetachedRouteHandle): void { }

    shouldAttach(route: ActivatedRouteSnapshot): boolean { return false; }

    retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null { return null; }

    shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
        return future.routeConfig === curr.routeConfig;
    }
}
As you can see, by default Angular does not use much of this functionality.  The only method that has any functionality is the shouldReuseRoute, everything else is null or false.  This article will discuss the ways these methods can be utilized together to enhance the functionality of the Angular router. 

In order to use a custom implementation of the RouteReuseStrategy, you need to complete two steps:

1. Create a class extending the RouteReuseStrategy – a sample minimal implemenation is below that uses the default Angular functionality as a starting point:

export class CustomReuseStrategy extends RouteReuseStrategy {
    retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
        return null;
    }

    shouldAttach(route: ActivatedRouteSnapshot): boolean {
        return false;
    }

    shouldDetach(route: ActivatedRouteSnapshot): boolean {
        return false;
    }

    shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
        return future.routeConfig === curr.routeConfig;
    }

    store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle | null): void {
    }
}
2. Create a provider to inject your custom class in your application’s main module:
providers: [
    { provide: RouteReuseStrategy, useClass: CustomReuseStrategy}
]

We will examine a sample situation and discover how these methods work and can enhance Angular functionality.

Example Situation

A common situation in web applications is a master-detail relationship.  There is some master page (e.g. search results) which the user can select individual details to view.  In this pattern, we are going to set a few constraints for how we want the application to perform: 

  • The master page should have some interactivity state that should be maintained between detail views such as sorting, filtering, etc. – we will represent this by a random number displayed in the component where if we maintain the same random number we can demonstrate a preservation of the state of the component
  • The detail is viewable via a distinct URL containing an ID for the detail record
  • The detail view should load the details upon routing and be able to run some initialization for the detail 
  • Components need to run cleanup code when destroyed such as unsubscribing from observables – we will represent this by logging a message to the console. 

We can implement two commonly used designs to meet the requirements.  As we will see, each can have some drawbacks and areas that we can gain additional control using the RouteReuseStrategy.  Both approaches will use the same detail page and a similar base class for the master page. 

Shared Components

This example will share as much as possible between the implementations to help distill the different approaches to only the differences. 

Master

The first shared functionality is a base abstract class to be used by the master page.

export abstract class MasterComponent implements OnInit, OnDestroy {
	public randoms: number[];
	public abstract displayName: string;

	ngOnInit(): void {
		this.randoms = [];
		const numberOfLinks = this.getRandom(10);
		for (let i = 0; i < numberOfLinks; i++) {
			this.randoms.push(this.getRandom(100));
		}
	}

	getRandom(max: number): number {
		return Math.floor(Math.random() * max);
	}

	ngOnDestroy(): void {
		console.log(`Destroy ${this.displayName}`);
	}
}

There are a few things happening in this component to take note of: 

  1. When the component initializes, it generates a new list of results – this represents user interaction with a list of items such as filtering or sorting
  2. The component logs a message with an identifier to be overridden by the consuming component

Detail

 Both approaches will use the same detail component:

@Component({
    selector: 'app-detail',
    template: ` 
      <div class="alert alert-primary" role="alert"> 
         <strong>Detail ID:</strong> {{routeParam}} 
      </div> 
 
      <div> 
         <strong>Random value:</strong> {{randomVal}} 
      </div> 
   `
})
export class DetailComponent implements OnInit, OnDestroy {
    public routeParam;
    public randomVal: number;

    constructor(private route: ActivatedRoute) { }

    ngOnInit(): void {
        this.route.params.subscribe(p => this.routeParam = p.detailId);

        this.randomVal = Math.floor(Math.random() * 100);
    }

    ngOnDestroy(): void {
        console.log(`Destroy detail: ${this.routeParam}`);
    }
}

There are a few things happening in this component to take note of: 

  1. When the component initializes, it gets the route parameter from the URL to display in the view – this represents any data loading for the detail from a back-end service
  2. Generates a new random value to display in the view – this represents some view initialization for the detail page
  3. The component logs a message with the route parameter when it is destroyed 

Parent-Child Design

The first design is a parent-child relationship where the contents of the master page stay on screen when viewing the detail.  This is best used when the detail is small and can reasonably fit on the same page. This method places a router-outlet in the master page to display the child content: 

@Component({
    selector: 'app-parent',
    template: ` 
      <h1>Parent Component</h1> 
      <ul class="nav nav-tabs"> 
         <li class="nav-item" *ngFor="let random of randoms"> 
            <a class="nav-link" [routerLink]="['detail', random]" routerLinkActive="active">Go to {{random}}</a> 
         </li> 
      </ul> 
 
      <router-outlet></router-outlet>`
})
export class ParentComponent extends MasterComponent {
    displayName = 'ParentComponent';

And adds the detail as a child route for the detail in the application route configuration:

{
    path: 'parent', 
    component: ParentComponent,
    children: [
        {
            path: 'detail/:detailId',
            component: DetailComponent
        }
    ]
}

We can test and see how this setup meets our needs when navigating between detail pages:

 

It is also important to note that the console for the application is completely empty, but when navigating elsewhere we see the destroy message. 

When comparing these results to our requirements, we see the following results: 

  • PASS – Master page is maintained 
  • PASS – Detail view is a unique URL 
  • FAIL – Detail view initialization does not run – the random value is the same every time even when routing between different details
  • PARTIAL – Detail component is never cleaned up between detail views, but it is after navigating away 

This approach initially results in one failure and one partial that we need to review.  Why does this fail to meet our requirement? 

Look at the Angular DefaultRouteReuseStrategy class – specifically the shouldReuseRoute method.  The implementation returns true when the destination route has the same route configuration as the source route.  When going from one detail to the next this is always true – only the parameter changes and the same route configuration and components are used.  When the shouldReuseRoute method returns true, Angular will not destroy the component tree in turning firing the ngOnInit and ngOnDestroy hooks – instead the only thing that happens it the router emits new values for the route parameters and other Observables.  Therefore, we see the Detail ID update but not the random number.   

We could easily solve the random number generation issue by moving the code into the subscribe callback on the params Observable.  This is often the best method in a detail type view to distinguish between code that creates the component and updates its contents when new data arrives or the route parameter changes.  Angular is designed around the idea of using Observables for communication and it shows here.  Most of the time, using the approach of binding to the param update instead of the overall life-cycle hooks will meet your application needs, but what happens when it doesn’t?  What if we really need the ngOnDestroy hook to fire when navigating away for that parameter and not just the component? 

Returning to the RouteReuseStrategy – we can provide a custom implementation of the shouldReuseRoute method. We can set up a custom class implementation as discussed earlier copying the functionality of the default Angular implementation.  The only change we will make now is to not reuse any routes: 

shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
    return false;
}

With only this change let’s test again and see what happens when we select a detail this time:

The ngOnDestroy hook runs all right, but we’ve made this far worse. The ngOnDestroy hook ran for the ParentComponent as well and we lost the state of the parent, we have new random links so a fresh search to the back-end has been called and the user loses all interaction. We can now see why Angular has the default functionality we saw earlier. When a routing event happens, it doesn’t just happen at the detail level. Angular needs to review the entire component tree generated by the route. When routing between siblings, the default functionality keeps the parent components intact and only rebuilds what is necessary – which not only gives a mostly expected behavior but has large performance benefits as well.

What we need is a way to direct Angular to only rebuild certain components but reuse others by default. We want to add the default functionality back in to shouldReuseRoute and only deviate when we specifically request it. We can accomplish this by using the data property on the route configuration which is designed exactly for this type of extensibility to a route. Let’s add a flag to the detail route definition:

{
   path: 'detail/:detailId',
   component: DetailComponent,
   data: {
      alwaysRefresh: true
   }
}
And check for this in shouldReuseRoute:
shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
    if (future.routeConfig === curr.routeConfig) {
        return !future.data.alwaysRefresh;
    } else {
        return false;
    }
}
Now to test again:

Excellent! We now are only rebuilding the detail component and we leave the parent component untouched. We have a design that meets all our requirements – just be aware of the consequences if the detail view is to have any children added to it.

Search-Detail

The other design approach is to create two separate pages, a search results page and a detail view page. This situation becomes far more desirable than the Master- Detail as the detail page gets larger and adds functionality that doesn’t easily fit on a single page. It is also very reasonable for a single site to employ both approaches – for example JIRA allows users to switch between both display options when reviewing search results.

To implement this approach, we create sibling routes instead of a parent-child relationship. These can be moved around to different parents depending on the desired URLs for the application, but we will put both under an empty parent to match the URL design of the Parent-Child approach:

@Component({
    selector: 'app-search',
    template: ` 
      <h1>Search Component</h1> 
      <ul class="nav nav-pills flex-column"> 
         <li class="nav-item" *ngFor="let random of randoms"> 
            <a class="nav-link" [routerLink]="['detail', random]" routerLinkActive="active">Go to {{random}}</a> 
         </li> 
      </ul> 
   `
})
export class SearchComponent extends MasterComponent {
    displayName = 'SearchComponent';
}
And the updated route configuration:
{
   path: 'search',
   children: [
      {
         path: '',
         component: SearchComponent
      },
      {
         path: 'detail/:detailId',
         component: DetailComponent
      }
   ]
}
We can revert the custom RouteReuseStrategy and disable it to see how this approach works by default:

 

 

Let’s review how this stacks up against our requirements: 

  • FAIL – The master page loses its context when we return to it 
  • PASS – Detail view is a unique URL 
  • PASS – Detail view initialization runs and gives a new random value 
  • PASS – Detail component is cleaned up between detail views 

This approach gives an opposite problem as the Parent-Child design.  We initialize the detail view as expected, but we lose the context of the search results.  

NOTE: The problem we had before with the initialization of the detail view still would exist if the detail view provided a direct link to another detail – this approach just doesn’t show it with this testing because we are using the master page to navigate between details.  

As we are navigating to a new page, we need some way to store the results of the search page somewhere in memory so we can retrieve it when returning to the page.  There are three commonly seen approaches to this: 

  1. Using a service or other state management technique, but this approach often requires manually binding everything in the component into a service which gets tedious and error prone as the application grows. 
  2. Use the Parent-Child approach and hide or cover the parent page when a detail is visible (e.g. detail is a modal window or some other overlay).  This again can work for simple implementations but has drawbacks for side effects and requires tightly coupling the components. If trying to use a full screen overlay this can also have adverse effects on usability if the user uses the back button to try to go back to the parent.
  3. RouteReuseStrategy to the rescue! 

To see how we can use RouteResuseStrategy we need to examine the process of routing and how the methods are used: 

  1. shouldDetach – This determines if the route the user is leaving should save the component state.  
  2. store – This stores the detached route if the method above returns true.
  3. shouldAttach – This determines if the route the user is navigating to should load the component state.
  4. retrieve – This loads the detached route if the method above returns true.

With these methods, we would expect the following for our custom implementation: 

  • shouldDetach returns true for the search results page and false for all other routes 
  • shouldAttach returns true when we have a saved search result 
  • we need a way to clear a saved search for when the user conducts a new search 

Let’s start with a similar approach we took before by adding a flag to the route configuration.  This could also be done through identifying the URL or any other identification technique: 

{
   path: 'search',
   children: [
      {
         path: '',
         component: SearchComponent,
         data: {
            saveComponent: true
         }
      },
      {
         path: 'detail/:detailId',
         component: DetailComponent
      }
   ]
}
This gives a straightforward approach to the shouldDetach method:
shouldDetach(route: ActivatedRouteSnapshot): boolean {
    return route.data.saveComponent;
}
And to store the component we need an internal map to use for storage, we can add this as a private property to the custom RouteReuseStrategy class:
private savedHandles = new Map<string, DetachedRouteHandle>(); 
And now to store the component under a unique key (this is important to prevent conflict between multiple saved routes if this strategy is used for multiple routes):
store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle | null): void {
   const key = this.getRouteKey(route);
   this.savedHandles.set(key, handle);
}

// Routes are stored as an array of route configs, so we can find any with url property and join them to create the URL for the rotue
private getRouteKey(route: ActivatedRouteSnapshot): string {
   return route.pathFromRoot.filter(u => u.url).map(u => u.url).join('/');
}
That completes the setup for saving the component when leaving, now to check if we should load a saved component when navigating to a route we can check if we have anything saved for the provided route and return the value as needed:
shouldAttach(route: ActivatedRouteSnapshot): boolean {
    return this.savedHandles.has(this.getRouteKey(route));
}

retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
   return this.savedHandles.get(this.getRouteKey(route));
}

Now we can test with this implementation of RouteReuseStrategy: 

Success!  We are maintaining the search result component state across navigation and creating a new detail component for each detail we view. 

There is one remaining item to address, what happens when we want to conduct a new search?  Even if the contents of the search result page are populated via an Observable, we may still want the ngOnInit hook to fire to reset the sorting, filters, pagination, etc.  We need a way to clear the saved component on demand.  Let’s create a method to do so in the custom RouteReuseStrategy class: 

public clearSavedHandle(key: string): void {
    this.savedHandles.delete(key);
}
We need a way to access this method however. Think back, what is the key to implementing a custom RouteReuseStrategy in the first place? We implemented a provider – we can use that! This would likely happen in a service and be executed whenever the user conducts a new search, but for simplicity lets add a button on the detail page:
<button class="btn btn-danger" (click)="clearStoredRoute()">Clear Search</button> 
And bind it in the component:
constructor(private route: ActivatedRoute, private routeReuseStrategy: RouteReuseStrategy) { }

clearStoredRoute(): void {
   (this.routeReuseStrategy as CustomReuseStrategy).clearSavedHandle('/router-reuse/search/');
}
Note we need to cast the injected object to our custom class – the injection token is for the abstract class so that’s all that Typescript gives us.  Also note that we need to provide the URL key that was generated in our class implementation as the saved handle key.  Keep this in mind when determining an implementation for triggering a save of a route.  This example mixed a URL route and route config data, but generally a single approach is preferred for simplicity and maintenance.  

A word of caution: do not use the Component type as a key!  This seems to be a perfect approach as it provides a unique key, can be made independent of URL changes, etc. however it may work in a development environment and fail in production when minification is applied.   

Let’s see this in action – first a test to make sure it still works as before then clearing the search: 

 

There we have it – a new search result any time the Clear Search button is clicked.  There is one very subtle problem left.  We have a new search component – but the old one never got destroyed! This would result in memory leaks and other potential unintended consequences.  When Angular detaches a component, it pulls it out of its responsibility and hands it over to our RouteReuseStrategy implementation.  When we cleared the saved handle, we just removed the object from our tracking map, but that never triggers the lifecycle hooks we would expect.  Let’s add some logic to address this in our custom RouteReuseStrategy: 

public clearSavedHandle(key: string): void {
   const handle = this.savedHandles.get(key);
   if (handle) {
      (handle as any).componentRef.destroy();
   }

   this.savedHandles.delete(key);
}

We have access to the component instance itself from the saved handle so all we need to do is call destroy to trigger the destruction life-cycle of the component. 

Conclusion

The most appropriate summary of the RouteReuseStrategy is this: with great power comes great responsibility.  It is an extremely powerful tool to customize Angular and greatly improve the user experience of your application – however there are various pitfalls to consider to safely implement its usage in a way that prevents some mysterious and difficult to identify bugs. There is one other downside – the functionality is not obvious to other developers as the majority of the functionality happens in the internals of Angular. Comments and documentation are crtitical for this approach to support fellow developers that are tasked with future maintenance. Even with the caveat, it remains a very power addition to the Angular toolbelt to quickly solve some common challenges. 

About Intertech

Founded in 1991, Intertech delivers software development consulting to Fortune 500, Government, and Leading Technology institutions, along with real-world based corporate education services. Whether you are a company looking to partner with a team of technology leaders who provide solutions, mentor staff and add true business value, or a developer interested in working for a company that invests in its employees, we’d like to meet you. Learn more about us.