An Angular search-results page loses its results, filters and scroll when users open a detail route and press Back. How do you keep it alive with RouteReuseStrategy?
answer
- detach instead of destroy
- shouldDetach, store, shouldAttach, retrieve
- opt in through route data
- ngOnInit does not run again
- evict with destroyDetachedRouteHandle
basics
~20 sProvide a custom RouteReuseStrategy whose shouldDetach returns true for the results route, store the DetachedRouteHandle, then let shouldAttach return true and retrieve return the handle on the way back. The component instance and DOM survive, so results, filters and scroll return intact.
solid answer
~40 sBy default the router destroys the results component when the detail route activates and creates a fresh one on Back. A custom `RouteReuseStrategy`, provided with `{provide: RouteReuseStrategy, useClass: ...}`, changes that: `shouldDetach()` returns true for routes flagged in `data`, `store()` keeps the opaque `DetachedRouteHandle` in a map, and on the way back `shouldAttach()` and `retrieve()` hand it to the outlet, which reinserts the same component instance and view. Extending `BaseRouteReuseStrategy` keeps the default `shouldReuseRoute`. Because nothing is destroyed or recreated, `ngOnDestroy` never runs on detach and `ngOnInit` never runs on reattach; use the outlet's `(attach)`/`(detach)` outputs or the route's observables to react. Cap the cache and discard evicted handles with `destroyDetachedRouteHandle()`, or they leak. With the DOM back in place, scroll restoration also lands correctly.
code
ts · 49 linesimport {Injectable} from '@angular/core';
import {
ActivatedRouteSnapshot,
BaseRouteReuseStrategy,
DetachedRouteHandle,
destroyDetachedRouteHandle,
} from '@angular/router';
const MAX_KEPT = 3;
@Injectable()
export class KeepAliveStrategy extends BaseRouteReuseStrategy {
private readonly cache = new Map<string, DetachedRouteHandle>();
override shouldDetach(route: ActivatedRouteSnapshot): boolean {
return route.data['keepAlive'] === true;
}
override store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle | null): void {
const key = this.key(route);
if (!handle) {
this.cache.delete(key); // reattached: forget it, do not destroy it
return;
}
this.cache.set(key, handle);
if (this.cache.size > MAX_KEPT) {
const [oldestKey, oldest] = this.cache.entries().next().value!;
destroyDetachedRouteHandle(oldest);
this.cache.delete(oldestKey);
}
}
override shouldAttach(route: ActivatedRouteSnapshot): boolean {
return this.cache.has(this.key(route));
}
override retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
return this.cache.get(this.key(route)) ?? null;
}
// Lets withAutoCleanupInjectors() keep the injectors that stored handles need.
retrieveStoredRouteHandles(): DetachedRouteHandle[] {
return [...this.cache.values()];
}
private key(route: ActivatedRouteSnapshot): string {
return route.pathFromRoot.map((r) => r.routeConfig?.path ?? '').join('/');
}
}go deeper
Know that the router destroys a component when you leave its route, and that a custom RouteReuseStrategy can keep it instead.
Walk through shouldDetach, store, shouldAttach and retrieve, and how route data flags which routes are kept.
Own the side effects: no ngOnInit or ngOnDestroy on reuse, background work still running, and a bounded cache evicted with destroyDetachedRouteHandle.
Decide where state should live: route reuse holds whole views in memory, so reserve it for expensive views and prefer service-held state for plain data.
## Why the page resets When the router leaves a route, the default strategy tells the `RouterOutlet` to **deactivate** it: the component is destroyed, its view is removed, and any state held in the component (the results array, the filter form, the window height the list produced) is gone. Pressing Back runs a normal navigation that creates a brand-new instance, which re-fetches and renders at the top. Keeping state in a service is one fix; **route reuse** keeps the whole component alive instead. ## The RouteReuseStrategy contract `RouteReuseStrategy` is an abstract class in `@angular/router`. The router calls it during every navigation: | Method | Called when | What your implementation decides | |---|---|---| | `shouldReuseRoute(future, curr)` | comparing the old and new route trees | keep the current instance for this node (default: same route config) | | `shouldDetach(route)` | a route with a component is being left | detach and keep it instead of destroying it | | `store(route, handle)` | right after a detach, and with `null` to clear | save or forget the `DetachedRouteHandle` | | `shouldAttach(route)` | a route is about to be activated | reattach a stored handle instead of creating a component | | `retrieve(route)` | after `shouldAttach` returned true | return the stored handle | The `DetachedRouteHandle` is **opaque**: it holds the component reference, its child outlet contexts and the `ActivatedRoute` subtree. You only store it and hand it back. When the router reattaches a handle it calls `store(route, null)`, so your map should delete the entry without destroying it. Extending **`BaseRouteReuseStrategy`** gives you the default behaviour for every method (never detach, reuse when the route config is the same), so you override only what you need. ## A keep-alive strategy for the results route 1. Flag the route: `{path: 'search', component: SearchResults, data: {keepAlive: true}}`. 2. In `shouldDetach`, return `route.data['keepAlive'] === true`. 3. In `store`, save non-null handles under a key and delete the key for `null`. 4. In `shouldAttach` and `retrieve`, look the key up. 5. Provide the class at the application root with `{provide: RouteReuseStrategy, useClass: KeepAliveStrategy}` next to `provideRouter(routes)`. The **key** matters. The route config object or the path from the root means one cached instance per route definition; a key built from the full URL gives one instance per distinct URL at the price of more memory. Angular's guide warns against keying on the route path when `canMatch` guards pick between several configs that share a path, because that can produce duplicate entries. ## What changes for the component - **No destroy, no init.** Detach does not call `ngOnDestroy` or fire `DestroyRef` callbacks; reattach does not rerun the constructor or `ngOnInit`. Code that refreshes data in `ngOnInit` will not refresh. - **Signals to react to.** The outlet emits `(attach)` and `(detach)` with the component instance, and the reattached `ActivatedRoute` is advanced to the new snapshot, so `queryParamMap` emits if the query changed while the page was away. - **Background work keeps running.** A detached view is out of the DOM and is not change-detected, but its timers and subscriptions are still alive. Pause polling on detach. ## Memory and cleanup - A strategy that stores every handle forever leaks components and their DOM. **Cap the cache** (for example, the last three result pages) and call **`destroyDetachedRouteHandle(handle)`** (public API since 22.2) for anything you evict. - Route-level `providers` create an environment injector per route. Since 22.2 the stable **`withAutoCleanupInjectors()`** feature destroys injectors of routes that are neither active nor stored; a custom strategy that does not extend `BaseRouteReuseStrategy` must implement `shouldDestroyInjector()`, and any strategy that stores handles should implement `retrieveStoredRouteHandles()` so the injectors those handles still need are not destroyed. The older `withExperimentalAutoCleanupInjectors()` is deprecated. ## Pitfalls seen in production - **Stale data on return.** A kept page shows exactly what it showed before. If the detail page edited an item, the list is now wrong until something refreshes it. - **New query, old instance.** If the key ignores the query string, a fresh search from elsewhere reattaches the old instance with the old results; the component must react to `queryParamMap` rather than read the query once. - **Kept routes inside kept routes.** Detaching a parent keeps its child outlets too; mixing reuse at several levels makes it hard to predict which instance appears. - **Logout.** Cached handles can hold a previous user's data; clear the cache when the session changes. ## Scroll comes back too Because the reattached view is inserted with its full DOM before the router emits its `Scroll` event, `withInMemoryScrolling({scrollPositionRestoration: 'enabled'})` restores the Back position onto a page that is already full height. That is why keep-alive and scroll restoration are usually configured together.
- The kept-alive results page must refresh a stale badge count when the user returns. Where does that code go, given ngOnInit does not rerun?React to reattachment instead of creation: bind `(attach)` on the `<router-outlet>` hosting the page and call a refresh method on the emitted instance, or subscribe to the route's `queryParamMap`, which emits when the reattached route is advanced to a changed URL. `ngOnInit` runs only once for the instance's whole life.
- Why must store(route, null) delete the cache entry without calling destroyDetachedRouteHandle()?The router calls `store` with `null` right after it has taken the handle back to reattach it. The component is live on screen at that moment, so destroying the handle would destroy the page the user is looking at. Destroy only handles you evict and will never reattach.
- When is keeping state in a service a better choice than a custom RouteReuseStrategy?When only data needs to survive, such as the query, the results and the page number. A service keeps that state without holding a whole component tree and its DOM in memory, and it avoids the lifecycle surprises of detached views. Route reuse pays off when the rendered view itself is expensive to rebuild or holds state that is hard to extract.
saying these in an interview costs you the question
- ngOnInit runs again when a detached route is reattached
- ngOnDestroy runs when the route is detached
- Stored handles are cleaned up automatically, so the cache cannot leak
- You can call a destroy method directly on a DetachedRouteHandle
- A detached component's timers and subscriptions pause on their own
- RouteReuseStrategy is configured per component with a decorator