Setting the Stage: A Two-Route App
Let's jump right into a fresh and interesting challenge.
Picture a simple application built around two routes:
- The first route shows a list of users.
- The second route presents the details of a single selected user.
Users can wander between these views by using links, and they can also cycle through different user profiles with straightforward "Previous" and "Next" buttons.
A visual demonstration might appear like the following:
The Initial Hurdle: Data Refetching
If you navigate back to a route you've previously visited, Angular will fetch the necessary data from the server all over again.
Of course, you can mitigate this with standard techniques like caching responses or introducing a global state store to remember data between transitions. However, there's a second, more subtle problem that often goes unnoticed.
The Deeper Issue: Vanishing DOM State
Every time you navigate away from a route, all the DOM state associated with that view is thrown away. For forms, this is especially painful. Any values the user has typed, any validation indicators, and any UI tweaks made to the components are instantly wiped clean when you return:
While a robust state management solution could theoretically solve this, it’s not a trivial task. You would be required to:
- Save the current state of the entire component.
- Manually reapply all those DOM changes when returning to restore the previous look and feel.
This means painstakingly patching values, error states, and all sorts of UI checks. And the problem doesn't stop with forms—widgets like accordions present the same headache.
That's where Angular's RouteReuseStrategy class comes to the rescue.
Understanding RouteReuseStrategy
Plenty of in-depth documentation exists about RouteReuseStrategy, but the short version is this: it lets you tell Angular exactly when it should keep a component alive versus destroying it during navigation.
In our specific case, we want Angular to hide away a component (instead of deleting it) when we leave a route, then bring it back perfectly intact when we come back.
Angular ships with a built-in strategy, but we are free to replace it with our own custom logic.
Crafting a Custom Strategy
Let’s build our own version of RouteReuseStrategy. Here is the initial code structure for it:
@Injectable({
providedIn: 'root',
})
export class CustomReuseStrategy implements RouteReuseStrategy {}
// main.ts
bootstrapApplication(AppComponent, {
providers: [
// ...
{
provide: RouteReuseStrategy,
useExisting: CustomReuseStrategy,
},
],
});
Note: Adding the Injectable decorator isn't essential, because Angular treats this class specially. However, including it gives us some extra flexibility for the future, which we will explore shortly.
Defining our Routing Rules
Time to set our own rules for how routes will behave:
- The
storeRoute: trueproperty will signal that a route should be kept alive and not destroyed. - The
noReuse: trueproperty will force a route to be created fresh, even if the route path is somehow the same.
The noReuse flag is a necessity for router parameters. For instance, without it, routes like /users/1 and /users/2 would share the same component instance, and you would accidentally inherit the state of the previous user.
Here’s a look at what those route definitions would be:
const routes: Routes = [
{
path: 'users',
component: UsersComponent,
data: {
storeRoute: true,
},
},
{
path: 'users/:id',
component: UserComponent,
data: {
noReuse: true,
storeRoute: true,
},
},
{
path: '**',
redirectTo: '/users',
},
];
Useful Utility Functions
To get this working right, we will need a few helpers:
- A function that builds the route's complete path so we have a unique identifier for it.
- A function to compare two objects, which is necessary for comparing the route parameters and query params.
Let's look at a possible implementation of 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
}
Putting the Strategy into Action
Let's write the actual class. First, we need to set up a place to keep our away components safe:
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> = {};
}
We don't have to dive deep into the mechanics of what DetachedRouteHandle is right now. Just know it's the magical handle that will restore our component when we revisit it.
Next, we get to fill in the required methods from RouteReuseStrategy.
The shouldDetach method is our gatekeeper, deciding if a route gets detached or not. Our guidance will be the storeRoute property in our config.
// Should we store the route? Defaults to false.
shouldDetach(route: ActivatedRouteSnapshot): boolean {
return !!route.data['storeRoute'];
}
The store method is our storage container called when we navigate away. Earlier, we made a helper to create a full path, and we'll use it here as the key.
// 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 };
}
The shouldAttach method checks if we can bring a route back from our cache. To ensure it's the right one, we compare all the parameters thoroughly, not forgetting about 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;
}
The retrieve method is the one that does the actual fetching of our route from its hiding place in the 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 determines if a route leading to the same path should be reused. Once more, we look at our own rules; true is the safe default for the majority of our routes:
// 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 helpers for flushing the cache
A cached route means the associated component instance stays alive indefinitely. As a result, ngOnInit won't fire a second time when that route is revisited. That's exactly what makes state preservation possible, but it also opens the door to memory leaks unless you're deliberate about cleanup.
To keep things safe, I suggest adding two small utility methods: one that clears a specific cached route, and another that wipes the entire cache. These make it straightforward to evict entries when they're no longer needed.
// 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();
}
}
Wrapping up
Here's what you end up with:
Returning to a cached route restores the component exactly as you left it. And whenever you need to, you can single out a specific entry or clear the whole cache manually.
The approach is essentially plug-and-play—dropping it into an existing project takes minimal effort. That said, treat it with care: used without discipline, it can turn your application into a memory leak magnet.
Check out the complete demo here.
AccademiaDev: text-based web development courses!
My focus is on delivering high-value material that skips the padding typical of conventional textbooks. Built from years of consulting and teaching experience, these interactive courses combine text, code examples, and quizzes—offering a practical, efficient path to learning that keeps you engaged without dragging things out.





