How to implement RouteReuseStrategy shouldDetach for specific routes in Angular 2
Hey Anders, great question!
I've got almost the same use case as you, and wanted to do the same thing! User search > get results > User navigates to result > User navigates back > BOOM blazing fast return to results, but you don't want to store the specific result that the user navigated to.
tl;dr
You need to have a class that implements RouteReuseStrategy
and provide your strategy in the ngModule
. If you want to modify when the route is stored, modify the shouldDetach
function. When it returns true
, Angular stores the route. If you want to modify when the route is attached, modify the shouldAttach
function. When shouldAttach
returns true, Angular will use the stored route in place of the requested route. Here's a Plunker for you to play around with.
About RouteReuseStrategy
By having asked this question, you already understand that RouteReuseStrategy allows you to tell Angular not to destroy a component, but in fact to save it for re-rendering at a later date. That's cool because it allows:
- Decreased server calls
- Increased speed
- AND the component renders, by default, in the same state it was left
That last one is important if you would like to, say, leave a page temporarily even though the user has entered a lot of text into it. Enterprise applications will love this feature because of the excessive amount of forms!
This is what I came up with to solve the problem. As you said, you need to make use of the RouteReuseStrategy
offered up by @angular/router in versions 3.4.1 and higher.
TODO
First Make sure your project has @angular/router version 3.4.1 or higher.
Next, create a file which will house your class that implements RouteReuseStrategy
. I called mine reuse-strategy.ts
and placed it in the /app
folder for safekeeping. For now, this class should look like:
import { RouteReuseStrategy } from '@angular/router';
export class CustomReuseStrategy implements RouteReuseStrategy {
}
(don't worry about your TypeScript errors, we're about to solve everything)
Finish the groundwork by providing the class to your app.module
. Note that you have not yet written CustomReuseStrategy
, but should go ahead and import
it from reuse-strategy.ts
all the same. Also import { RouteReuseStrategy } from '@angular/router';
@NgModule({
[...],
providers: [
{provide: RouteReuseStrategy, useClass: CustomReuseStrategy}
]
)}
export class AppModule {
}
The final piece is writing the class which will control whether or not routes get detached, stored, retrieved, and reattached. Before we get to the old copy/paste, I'll do a short explanation of mechanics here, as I understand them. Reference the code below for the methods I'm describing, and of course, there's plenty of documentation in the code.
- When you navigate,
shouldReuseRoute
fires. This one is a little odd to me, but if it returnstrue
, then it actually reuses the route you're currently on and none of the other methods are fired. I just return false if the user is navigating away. - If
shouldReuseRoute
returnsfalse
,shouldDetach
fires.shouldDetach
determines whether or not you want to store the route, and returns aboolean
indicating as much. This is where you should decide to store/not to store paths, which I would do by checking an array of paths you want stored againstroute.routeConfig.path
, and returning false if thepath
does not exist in the array. - If
shouldDetach
returnstrue
,store
is fired, which is an opportunity for you to store whatever information you would like about the route. Whatever you do, you'll need to store theDetachedRouteHandle
because that's what Angular uses to identify your stored component later on. Below, I store both theDetachedRouteHandle
and theActivatedRouteSnapshot
into a variable local to my class.
So, we've seen the logic for storage, but what about navigating to a component? How does Angular decide to intercept your navigation and put the stored one in its place?
- Again, after
shouldReuseRoute
has returnedfalse
,shouldAttach
runs, which is your chance to figure out whether you want to regenerate or use the component in memory. If you want to reuse a stored component, returntrue
and you're well on your way! - Now Angular will ask you, "which component do you want us to use?", which you will indicate by returning that component's
DetachedRouteHandle
fromretrieve
.
That's pretty much all the logic you need! In the code for reuse-strategy.ts
, below, I've also left you a nifty function that will compare two objects. I use it to compare the future route's route.params
and route.queryParams
with the stored one's. If those all match up, I want to use the stored component instead of generating a new one. But how you do it is up to you!
reuse-strategy.ts
/**
* reuse-strategy.ts
* by corbfon 1/6/17
*/
import { ActivatedRouteSnapshot, RouteReuseStrategy, DetachedRouteHandle } from '@angular/router';
/** Interface for object which can store both:
* An ActivatedRouteSnapshot, which is useful for determining whether or not you should attach a route (see this.shouldAttach)
* A DetachedRouteHandle, which is offered up by this.retrieve, in the case that you do want to attach the stored route
*/
interface RouteStorageObject {
snapshot: ActivatedRouteSnapshot;
handle: DetachedRouteHandle;
}
export class CustomReuseStrategy implements RouteReuseStrategy {
/**
* Object which will store RouteStorageObjects indexed by keys
* The keys will all be a path (as in route.routeConfig.path)
* This allows us to see if we've got a route stored for the requested path
*/
storedRoutes: { [key: string]: RouteStorageObject } = {};
/**
* Decides when the route should be stored
* If the route should be stored, I believe the boolean is indicating to a controller whether or not to fire this.store
* _When_ it is called though does not particularly matter, just know that this determines whether or not we store the route
* An idea of what to do here: check the route.routeConfig.path to see if it is a path you would like to store
* @param route This is, at least as I understand it, the route that the user is currently on, and we would like to know if we want to store it
* @returns boolean indicating that we want to (true) or do not want to (false) store that route
*/
shouldDetach(route: ActivatedRouteSnapshot): boolean {
let detach: boolean = true;
console.log("detaching", route, "return: ", detach);
return detach;
}
/**
* Constructs object of type `RouteStorageObject` to store, and then stores it for later attachment
* @param route This is stored for later comparison to requested routes, see `this.shouldAttach`
* @param handle Later to be retrieved by this.retrieve, and offered up to whatever controller is using this class
*/
store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
let storedRoute: RouteStorageObject = {
snapshot: route,
handle: handle
};
console.log( "store:", storedRoute, "into: ", this.storedRoutes );
// routes are stored by path - the key is the path name, and the handle is stored under it so that you can only ever have one object stored for a single path
this.storedRoutes[route.routeConfig.path] = storedRoute;
}
/**
* Determines whether or not there is a stored route and, if there is, whether or not it should be rendered in place of requested route
* @param route The route the user requested
* @returns boolean indicating whether or not to render the stored route
*/
shouldAttach(route: ActivatedRouteSnapshot): boolean {
// this will be true if the route has been stored before
let canAttach: boolean = !!route.routeConfig && !!this.storedRoutes[route.routeConfig.path];
// this decides whether the route already stored should be rendered in place of the requested route, and is the return value
// at this point we already know that the paths match because the storedResults key is the route.routeConfig.path
// so, if the route.params and route.queryParams also match, then we should reuse the component
if (canAttach) {
let willAttach: boolean = true;
console.log("param comparison:");
console.log(this.compareObjects(route.params, this.storedRoutes[route.routeConfig.path].snapshot.params));
console.log("query param comparison");
console.log(this.compareObjects(route.queryParams, this.storedRoutes[route.routeConfig.path].snapshot.queryParams));
let paramsMatch: boolean = this.compareObjects(route.params, this.storedRoutes[route.routeConfig.path].snapshot.params);
let queryParamsMatch: boolean = this.compareObjects(route.queryParams, this.storedRoutes[route.routeConfig.path].snapshot.queryParams);
console.log("deciding to attach...", route, "does it match?", this.storedRoutes[route.routeConfig.path].snapshot, "return: ", paramsMatch && queryParamsMatch);
return paramsMatch && queryParamsMatch;
} else {
return false;
}
}
/**
* Finds the locally stored instance of the requested route, if it exists, and returns it
* @param route New route the user has requested
* @returns DetachedRouteHandle object which can be used to render the component
*/
retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
// return null if the path does not have a routerConfig OR if there is no stored route for that routerConfig
if (!route.routeConfig || !this.storedRoutes[route.routeConfig.path]) return null;
console.log("retrieving", "return: ", this.storedRoutes[route.routeConfig.path]);
/** returns handle when the route.routeConfig.path is already stored */
return this.storedRoutes[route.routeConfig.path].handle;
}
/**
* Determines whether or not the current route should be reused
* @param future The route the user is going to, as triggered by the router
* @param curr The route the user is currently on
* @returns boolean basically indicating true if the user intends to leave the current route
*/
shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
console.log("deciding to reuse", "future", future.routeConfig, "current", curr.routeConfig, "return: ", future.routeConfig === curr.routeConfig);
return future.routeConfig === curr.routeConfig;
}
/**
* This nasty bugger finds out whether the objects are _traditionally_ equal to each other, like you might assume someone else would have put this function in vanilla JS already
* One thing to note is that it uses coercive comparison (==) on properties which both objects have, not strict comparison (===)
* Another important note is that the method only tells you if `compare` has all equal parameters to `base`, not the other way around
* @param base The base object which you would like to compare another object to
* @param compare The object to compare to base
* @returns boolean indicating whether or not the objects have all the same properties and those properties are ==
*/
private compareObjects(base: any, compare: any): boolean {
// loop through all properties in base object
for (let baseProperty in base) {
// determine if comparrison object has that property, if not: return false
if (compare.hasOwnProperty(baseProperty)) {
switch(typeof base[baseProperty]) {
// if one is object and other is not: return false
// if they are both objects, recursively call this comparison function
case 'object':
if ( typeof compare[baseProperty] !== 'object' || !this.compareObjects(base[baseProperty], compare[baseProperty]) ) { return false; } break;
// if one is function and other is not: return false
// if both are functions, compare function.toString() results
case 'function':
if ( typeof compare[baseProperty] !== 'function' || base[baseProperty].toString() !== compare[baseProperty].toString() ) { return false; } break;
// otherwise, see if they are equal using coercive comparison
default:
if ( base[baseProperty] != compare[baseProperty] ) { return false; }
}
} else {
return false;
}
}
// returns true only after false HAS NOT BEEN returned through all loops
return true;
}
}
Behavior
This implementation stores every unique route that the user visits on the router exactly once. This will continue to add to the components stored in memory throughout the user's session on the site. If you'd like to limit the routes that you store, the place to do it is the shouldDetach
method. It controls which routes you save.
Example
Say your user searches for something from the homepage, which navigates them to the path search/:term
, which might appear like www.yourwebsite.com/search/thingsearchedfor
. The search page contains a bunch of search results. You'd like to store this route, in case they want to come back to it! Now they click a search result and get navigated to view/:resultId
, which you do not want to store, seeing as they'll probably be there only once. With the above implementation in place, I would simply change the shouldDetach
method! Here's what it might look like:
First off let's make an array of paths we want to store.
private acceptedRoutes: string[] = ["search/:term"];
now, in shouldDetach
we can check the route.routeConfig.path
against our array.
shouldDetach(route: ActivatedRouteSnapshot): boolean {
// check to see if the route's path is in our acceptedRoutes array
if (this.acceptedRoutes.indexOf(route.routeConfig.path) > -1) {
console.log("detaching", route);
return true;
} else {
return false; // will be "view/:resultId" when user navigates to result
}
}
Because Angular will only store one instance of a route, this storage will be lightweight, and we'll only be storing the component located at search/:term
and not all the others!
Additional Links
Although there's not much documentation out there yet, here are a couple links to what does exist:
Angular Docs: https://angular.io/docs/ts/latest/api/router/index/RouteReuseStrategy-class.html
Intro Article: https://www.softwarearchitekt.at/post/2016/12/02/sticky-routes-in-angular-2-3-with-routereusestrategy.aspx
nativescript-angular's default Implementation of RouteReuseStrategy: https://github.com/NativeScript/nativescript-angular/blob/cb4fd3a/nativescript-angular/router/ns-route-reuse-strategy.ts
Don't be intimidated by the accepted answer, this is pretty straightforward. Here's a quick answer what you need. I would recommend at least reading the accepted answer, as it's full of great detail.
This solution doesn't do any parameter comparison like the accepted answer but it will work fine for storing a set of routes.
app.module.ts imports:
import { RouteReuseStrategy } from '@angular/router';
import { CustomReuseStrategy, Routing } from './shared/routing';
@NgModule({
//...
providers: [
{ provide: RouteReuseStrategy, useClass: CustomReuseStrategy },
]})
shared/routing.ts:
export class CustomReuseStrategy implements RouteReuseStrategy {
routesToCache: string[] = ["dashboard"];
storedRouteHandles = new Map<string, DetachedRouteHandle>();
// Decides if the route should be stored
shouldDetach(route: ActivatedRouteSnapshot): boolean {
return this.routesToCache.indexOf(route.routeConfig.path) > -1;
}
//Store the information for the route we're destructing
store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
this.storedRouteHandles.set(route.routeConfig.path, handle);
}
//Return true if we have a stored route object for the next route
shouldAttach(route: ActivatedRouteSnapshot): boolean {
return this.storedRouteHandles.has(route.routeConfig.path);
}
//If we returned true in shouldAttach(), now return the actual route data for restoration
retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
return this.storedRouteHandles.get(route.routeConfig.path);
}
//Reuse the route if we're going to and from the same route
shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
return future.routeConfig === curr.routeConfig;
}
}
In the addition to the accepted answer (by Corbfon) and Chris Fremgen's shorter and more straightforward explanation, I want to add a more flexible way of handling routes that should use the reuse strategy.
Both answers store the routes we want to cache in an array and then check if the current route path is in the array or not. This check is done in shouldDetach
method.
I find this approach inflexible because if we want to change the name of the route we would need to remember to also change the route name in our CustomReuseStrategy
class. We may either forget to change it or some other developer in our team may decide to change the route name not even knowing about the existence of RouteReuseStrategy
.
Instead of storing the routes we want to cache in an array, we can mark them directly in RouterModule
using data
object. This way even if we change the route name, the reuse strategy would still be applied.
{
path: 'route-name-i-can-change',
component: TestComponent,
data: {
reuseRoute: true
}
}
And then in shouldDetach
method we make a use of that.
shouldDetach(route: ActivatedRouteSnapshot): boolean {
return route.data.reuseRoute === true;
}