Let’s dive into a fun new exercise!
Imagine you have an application with two routes:
- One displays a list of users.
- The other shows details for a specific user.
You can navigate between these routes by clicking links, and you can also switch between user details using "Previous" and "Next" buttons.
Here’s an example of what it might look like:
The First Challenge
When navigating back to a route you've already visited, the application fetches the data from the server again.
While this can be addressed in several ways—such as implementing a cache or using a global store to maintain state across route transitions—there’s another, more nuanced issue.
The Second Challenge: Losing DOM State
When navigating away from a route, you lose all DOM state. This is particularly problematic with forms. When you return to the form, any entered data, validation states, and UI modifications are lost:
Although proper state management could solve this, it’s not straightforward. You’d need to:
- Save the component state.
- Reapply all DOM modifications to restore the previous look and behavior.
This involves patching form values, states, errors, and more. And it’s not limited to forms—other UI elements like accordions also require similar treatment.
Fortunately, Angular provides a powerful tool to address this: the RouteReuseStrategy
class.
RouteReuseStrategy: what does it do?
You’ll find plenty of in-depth articles on RouteReuseStrategy
, but here’s the gist: it allows you to control when Angular should dispose of or retain a component during navigation.
In our scenario, we want Angular to “set aside” (hide) components instead of destroying them when leaving a route, and to retrieve them as-is when navigating back.
By default, Angular uses an internal strategy, but we can override it with a custom implementation.
Creating a Custom Reuse Strategy
Here’s how we can define a custom RouteReuseStrategy
:
@Injectable({
providedIn: 'root',
})
export class CustomReuseStrategy implements RouteReuseStrategy {}
// main.ts
bootstrapApplication(AppComponent, {
providers: [
// ...
{
provide: RouteReuseStrategy,
useExisting: CustomReuseStrategy,
},
],
});
Note: Marking it as Injectable
isn’t strictly required since Angular handles it internally, but this approach offers flexibility—more on that in a moment.
Defining Route Reuse Behavior
Let’s establish a convention for managing routes:
- Use
storeRoute: true
for routes that should not be destroyed. - Use
noReuse: true
for routes that must not reuse components, even if the paths are identical.
The noReuse
flag is critical for routes with parameters (e.g., /users/1
vs. /users/2
). Without it, these routes would share the same component instance, leading to unintended state sharing.
Here’s how your routes might look:
const routes: Routes = [
{
path: 'users',
component: UsersComponent,
data: {
storeRoute: true,
},
},
{
path: 'users/:id',
component: UserComponent,
data: {
noReuse: true,
storeRoute: true,
},
},
{
path: '**',
redirectTo: '/users',
},
];
A couple of helpers
For our implementation we'll need a couple of helpers:
- A function which constructs the full path of a route, in order to identify it
- A function which compares two objects, for comparing parameters and query parameters
Here an example for both:
function compareObjects(a: any, b: any): boolean {
return Object.keys(a).every(prop =>
b.hasOwnProperty(prop) &&
(typeof a[prop] === typeof b[prop]) &&
(
(typeof a[prop] === "object" && compareObjects(a[prop], b[prop])) ||
(typeof a[prop] === "function" && a[prop].toString() === b[prop].toString()) ||
a[prop] == b[prop]
)
);
}
// Returns the full path of a route, as a string
export function getFullPath(route: ActivatedRouteSnapshot): string {
return route.pathFromRoot
.map(v => v.url.map(segment => segment.toString()).join("/"))
.join("/")
.trim()
.replace(/\/$/, ""); // Remove trailing slash
}
Implementing RouteReuseStrategy
Let's implement the class. First, we must create an object to cache our components:
import {
ActivatedRouteSnapshot,
DetachedRouteHandle,
RouteReuseStrategy
} from "@angular/router";
interface StoredRoute {
route: ActivatedRouteSnapshot;
handle: DetachedRouteHandle;
}
@Injectable({
providedIn: 'root'
})
export class CustomReuseStrategy implements RouteReuseStrategy {
storedRoutes: Record<string, StoredRoute | null> = {};
}
I won't go into details about DetachedRouteHandle
, but that's what will bring our component back when the route is visited again.
Now we must implement the required methods of RouteReuseStrategy
.
A shouldDetach
method decides whether to detach a route or not: we'll decide based on the storeRoute
property in our route configuration.
// Should we store the route? Defaults to false.
shouldDetach(route: ActivatedRouteSnapshot): boolean {
return !!route.data['storeRoute'];
}
A store
method allows us to store our route when navigating away from it. We'll store it by full path with our helper from before:
// Store the route
store(
route: ActivatedRouteSnapshot,
handle: DetachedRouteHandle
): void {
// Ex. users/1, users/2, users/3, ...
const key = getFullPath(route);
this.storedRoutes[key] = { route, handle };
}
A shouldAttach
method decides whether to grab the route from our cache or not. Here, we compare all route parameters, including queryParams
.
// Should we retrieve a route from the store?
shouldAttach(route: ActivatedRouteSnapshot): boolean {
const key = getFullPath(route);
const isStored = !!route.routeConfig && !!this.storedRoutes[key];
if (isStored) {
// Compare params and queryParams.
// Params, however, have already been compared because the key includes them.
const paramsMatch = compareObjects(
route.params,
this.storedRoutes[key]!.route.params
);
const queryParamsMatch = compareObjects(
route.queryParams,
this.storedRoutes[key]!.route.queryParams
);
return paramsMatch && queryParamsMatch;
}
return false;
}
A retrieve
method is used to grab the route from our cache:
// Retrieve from the store (it only needs the handle)
retrieve(route: ActivatedRouteSnapshot) {
const key = getFullPath(route);
if (!route.routeConfig || !this.storedRoutes[key]) return null;
return this.storedRoutes[key].handle;
}
Finally, shouldReuseRoute
decides whether a route with the same path as the previous one should be reused. Again, we'll refer to our convention, and true
will be the default:
// Should the route be reused?
shouldReuseRoute(
previous: ActivatedRouteSnapshot,
next: ActivatedRouteSnapshot
): boolean {
const isSameConfig = previous.routeConfig === next.routeConfig;
const shouldReuse = !next.data['noReuse'];
return isSameConfig && shouldReuse;
}
Convenience methods for purging the cache
It's important to understand that when a route is cached, the associated component is never destroyed. This also means that ngOnInit
will not be triggered again when the user navigates back to the route. While this can be beneficial for preserving state, it also introduces the risk of memory leaks if not handled properly.
To mitigate this, I recommend implementing convenience methods to manually purge the cache: one for clearing a single route and another for clearing all routes.
// Destroys the components of all stored routes
clearAllRoutes() {
for (const key in this.storedRoutes) {
if (this.storedRoutes[key]!.handle) {
this.destroyComponent(this.storedRoutes[key]!.handle);
}
}
this.storedRoutes = {};
}
// Destroys the component of a particular route.
clearRoute(fullPath: string) {
if (this.storedRoutes[fullPath]?.handle) {
this.destroyComponent(this.storedRoutes[fullPath].handle);
this.storedRoutes[fullPath] = null;
}
}
// A bit of a hack: manually destroy a particular component.
private destroyComponent(handle: DetachedRouteHandle): void {
const componentRef: ComponentRef<any> = (handle as any).componentRef;
if (componentRef) {
componentRef.destroy();
}
}
Conclusion
Here's the end result:
When navigating back to a cached route, the component retains all of its state seamlessly. Additionally, you can manually clear the cache whenever needed.
This solution is highly "plug-and-play" and can be easily integrated into your projects. However, use it wisely—improper use can make your app prone to memory leaks.
AccademiaDev: text-based web development courses!
I believe in providing concise, valuable content without the unnecessary length and filler found in traditional books. Drawing from my years as a consultant and trainer, these resources—designed as interactive, online courses—deliver practical insights through text, code snippets, and quizzes, providing an efficient and engaging learning experience.
Top comments (0)