adllm Insights logo adllm Insights logo

Preserving Complex Component State in Angular with Custom RouteReuseStrategy

Published on by The adllm Team. Last modified: . Tags: Angular RouteReuseStrategy Routing Component State Angular Performance TypeScript

Angular’s default behavior when navigating between routes is to destroy the component instance associated with the previous route and create a new one for the next. While efficient for many scenarios, this approach leads to the loss of all component-specific state – form inputs, scroll positions, UI element states (like open accordions or selected tabs), and fetched data that hasn’t been globalized. For applications with components managing complex or expensive-to-rebuild state, this default behavior can degrade user experience and performance.

The solution lies in implementing a custom RouteReuseStrategy. This powerful Angular feature allows developers to define precisely when and how components are detached, stored, retrieved, and reattached, effectively “keeping them alive” across navigations. This article offers a deep dive into creating and applying a custom RouteReuseStrategy to preserve the state of complex Angular components.

Understanding Angular’s Default Routing Behavior

By default, Angular uses the BaseRouteReuseStrategy. This strategy reuses a component instance only when navigating to the same route configuration but with different route parameters (e.g., from /user/1 to /user/2). If you navigate to a completely different route (e.g., from /user/1 to /settings), the /user/1 component is destroyed. Upon returning to /user/1, a brand new instance is created, devoid of its previous state.

This is often fine, but consider a complex search results page with multiple filters applied, a long data entry form, or a dashboard widget that has been customized by the user. Losing this state upon navigating away and back is frustrating and inefficient.

The Core Problem: Preserving Rich Component State

Preserving component state is crucial for:

  • User Experience (UX): Users expect applications to remember their interactions, such as filled form fields, scroll positions on long lists, or applied data filters. Resetting this state forces users to repeat actions.
  • Performance: Re-initializing components, re-fetching data, and re-rendering complex UIs can be computationally expensive and time-consuming. Preserving components can lead to snappier perceived performance.

A custom RouteReuseStrategy addresses these by allowing specific component instances to be detached from the DOM but kept in memory, ready to be reattached with their state intact.

Introducing RouteReuseStrategy for Custom Control

The RouteReuseStrategy is an abstract class from @angular/router that you can extend. It defines five key methods that the Angular router calls during navigation to determine component reuse behavior:

  1. shouldDetach(route: ActivatedRouteSnapshot): boolean: Determines if the component for the current route should be detached (stored) rather than destroyed. The ActivatedRouteSnapshot provides information about the route.
  2. store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle | null): void: Stores the detached route (component tree) if shouldDetach returned true. The DetachedRouteHandle is an opaque object representing the stored component.
  3. shouldAttach(route: ActivatedRouteSnapshot): boolean: Determines if a previously stored route should be reattached. This is called when navigating to a route.
  4. retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null: Retrieves the stored DetachedRouteHandle if shouldAttach returned true.
  5. shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean: Determines if the router should reuse the route instance (and its component) when navigating between routes that share the same route configuration, typically when only parameters change.

By implementing these methods, we gain fine-grained control over component lifecycles during navigation.

Step-by-Step Implementation of a Custom RouteReuseStrategy

Let’s build a CustomReuseStrategy that allows us to mark specific routes for state preservation.

1. Defining the Strategy Class

First, create the TypeScript file for our custom strategy, for example, custom-reuse-strategy.ts.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// src/app/custom-reuse-strategy.ts
import {
    ActivatedRouteSnapshot,
    DetachedRouteHandle,
    RouteReuseStrategy
} from '@angular/router';

export class CustomReuseStrategy implements RouteReuseStrategy {
    // Storage for detached route handles, keyed by a unique route identifier.
    private storedHandles: { [key: string]: DetachedRouteHandle | null } = {};

    // Method implementations will follow.
    public shouldDetach(route: ActivatedRouteSnapshot): boolean {
        // Implementation below
        return false;
    }

    public store(
        route: ActivatedRouteSnapshot,
        handle: DetachedRouteHandle | null
    ): void {
        // Implementation below
    }

    public shouldAttach(route: ActivatedRouteSnapshot): boolean {
        // Implementation below
        return false;
    }

    public retrieve(
        route: ActivatedRouteSnapshot
    ): DetachedRouteHandle | null {
        // Implementation below
        return null;
    }

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

This basic structure initializes an empty object storedHandles to cache our component states.

2. Identifying Routes for Caching (The data Property)

A common way to mark routes for caching is by using the data property in their route configuration.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/app/app-routing.module.ts (or your routing module)
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ComplexFormComponent } from './complex-form/complex-form.component';
import { OtherPageComponent } from './other-page/other-page.component';
// Assume ComplexFormComponent and OtherPageComponent are defined

const routes: Routes = [
  {
    path: 'complex-form',
    component: ComplexFormComponent,
    data: { reuseComponent: true } // Our custom flag
  },
  {
    path: 'other-page',
    component: OtherPageComponent
  },
  // other routes...
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

Here, data: { reuseComponent: true } signals our CustomReuseStrategy to preserve the ComplexFormComponent.

3. Implementing shouldDetach

This method checks if the route is marked for reuse. If so, it returns true, indicating Angular should detach and store this component.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Inside CustomReuseStrategy class

// Generates a unique key for a route.
// For simplicity, we use the route's path. More complex keys might be
// needed if parameters should lead to different cached instances.
private getRouteKey(route: ActivatedRouteSnapshot): string {
    // For child routes, you might need to concatenate parent paths.
    // e.g., route.pathFromRoot.map(r => r.url.join('/'))
    // .filter(p => p).join('/');
    // For this example, routeConfig.path is usually sufficient.
    return route.routeConfig ? route.routeConfig.path || '' : '';
}

public shouldDetach(route: ActivatedRouteSnapshot): boolean {
    const shouldDetach = !!route.data && !!route.data['reuseComponent'];
    if (shouldDetach) {
        // console.log('[CustomReuseStrategy] Detaching route:',
        // this.getRouteKey(route));
    }
    return shouldDetach;
}

The getRouteKey helper function generates an identifier for storing the route. Using route.routeConfig.path is a common starting point.

4. Implementing store

If shouldDetach returns true, Angular calls store. Here, we save the DetachedRouteHandle in our storedHandles map.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Inside CustomReuseStrategy class

public store(
    route: ActivatedRouteSnapshot,
    handle: DetachedRouteHandle | null
): void {
    if (handle && route.data && route.data['reuseComponent']) {
        const key = this.getRouteKey(route);
        this.storedHandles[key] = handle;
        // console.log('[CustomReuseStrategy] Stored route:', key);
    }
}

5. Implementing shouldAttach

When navigating to a route, shouldAttach determines if we should use a stored component.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Inside CustomReuseStrategy class

public shouldAttach(route: ActivatedRouteSnapshot): boolean {
    const key = this.getRouteKey(route);
    const canAttach = !!route.data &&
                      !!route.data['reuseComponent'] &&
                      !!this.storedHandles[key];
    if (canAttach) {
        // console.log('[CustomReuseStrategy] Attaching route:', key);
    }
    return canAttach;
}```

### 6. Implementing `retrieve`

If `shouldAttach` returns `true`, `retrieve` is called to get the actual `DetachedRouteHandle`.

```typescript
// Inside CustomReuseStrategy class

public retrieve(
    route: ActivatedRouteSnapshot
): DetachedRouteHandle | null {
    const key = this.getRouteKey(route);
    if (route.data && route.data['reuseComponent'] && this.storedHandles[key]) {
        // console.log('[CustomReuseStrategy] Retrieved route:', key);
        return this.storedHandles[key];
    }
    return null;
}

Note: Some implementations might choose to remove the handle from storedHandles after retrieval if one-time reuse is desired. For continuous reuse until explicitly cleared, keep it.

7. Implementing shouldReuseRoute

This method is crucial. It determines if an already active route configuration should be reused (e.g., when only route parameters change: /product/1 to /product/2).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Inside CustomReuseStrategy class

public shouldReuseRoute(
    future: ActivatedRouteSnapshot,
    curr: ActivatedRouteSnapshot
): boolean {
    // Default Angular behavior: reuse if route configuration is the same.
    // This handles scenarios like parameter changes for the same component.
    // If future.routeConfig !== curr.routeConfig, it means we are navigating
    // to a different component. In that case, our detach/attach logic
    // (shouldDetach, store, shouldAttach, retrieve) will handle
    // whether to reuse a previously stored component instance for 'future'.
    // If we're navigating to a component marked for reuseComponent: true,
    // and its config is different from current, shouldReuseRoute returns false,
    // then shouldDetach (for curr) and shouldAttach (for future) are called.
    let reuse = future.routeConfig === curr.routeConfig;

    // If a route is marked with 'alwaysRefresh', don't reuse it even if
    // the config is the same (e.g., force reload on param change).
    if (future.data && future.data['alwaysRefresh']) {
        reuse = false;
    }
    // console.log(`[CustomReuseStrategy] shouldReuseRoute for
    // ${this.getRouteKey(future)}: ${reuse}`);
    return reuse;
}

For most custom reuse strategies focused on preserving entire components across different routes, the logic here often defers to future.routeConfig === curr.routeConfig. If this is false, then the shouldDetach/shouldAttach logic takes over to potentially reuse a stored component. If true, the existing component instance is reused, and it receives new parameters via observables like ActivatedRoute.paramMap.

8. Providing the Custom Strategy

Finally, register your CustomReuseStrategy in the providers array of your AppModule (or a core module).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/app/app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router'; // Ensure this is imported
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { CustomReuseStrategy } from './custom-reuse-strategy';
// Import your components like ComplexFormComponent, OtherPageComponent

@NgModule({
  declarations: [
    AppComponent,
    // ComplexFormComponent,
    // OtherPageComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule
  ],
  providers: [
    { provide: RouteReuseStrategy, useClass: CustomReuseStrategy }
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

Handling Component Reactivation

When a component is reattached from a DetachedRouteHandle, standard Angular lifecycle hooks like ngOnInit and ngOnChanges are not called again. ngOnDestroy is also not called when it’s detached. If you need to execute logic when a component becomes active again (e.g., to refresh some data or reset parts of its state), consider these approaches:

  1. <router-outlet> Events: The <router-outlet> emits (activate) and (deactivate) events.

    1
    2
    
    <!-- In your app.component.html or relevant parent component -->
    <router-outlet (activate)="onActivate($event)" (deactivate)="onDeactivate($event)"></router-outlet>
    
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    // In the component containing the router-outlet
    onActivate(componentInstance: any) {
      // 'componentInstance' is the instance of the activated component
      if (typeof componentInstance.onRouteReattach === 'function') {
        // Call a custom method if it exists on the reattached component
        componentInstance.onRouteReattach();
      }
    }
    
    onDeactivate(componentInstance: any) {
      // Called when component is detached or destroyed
      if (typeof componentInstance.onRouteDeactivate === 'function') {
        componentInstance.onRouteDeactivate();
      }
    }
    

    Your cached components can then implement onRouteReattach() and onRouteDeactivate() methods.

  2. Listening to Router.events: Subscribe to Router.events (specifically, events like NavigationEnd) from the Router service within the component itself and check if the current URL matches the component’s route.

Managing the Cache

Storing component states indefinitely can lead to memory issues or stale data. It’s crucial to provide a mechanism to clear the cache.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Inside CustomReuseStrategy class

// Clears all stored handles
public clearAllCache(): void {
    this.storedHandles = {};
    // console.log('[CustomReuseStrategy] All cache cleared.');
}

// Clears a specific cached route by its key
public clearCacheForKey(key: string): void {
    if (this.storedHandles[key]) {
        // Angular doesn't provide a direct way to "destroy" a
        // DetachedRouteHandle. Setting it to null and removing the reference
        // allows it to be garbage collected if not referenced elsewhere.
        // If the component has an ngOnDestroy, it won't be called here
        // as it's already detached. Manual cleanup within the component
        // might be needed before detaching if resources need releasing.
        delete this.storedHandles[key];
        // console.log('[CustomReuseStrategy] Cache cleared for key:', key);
    }
}

// Example method to clear cache for a specific path (e.g., 'complex-form')
public clearCacheForPath(path: string): void {
    // This assumes path is the key used in storedHandles.
    // If your getRouteKey is more complex, adapt accordingly.
    this.clearCacheForKey(path);
}

You can expose these methods through an injectable service that holds a reference to the CustomReuseStrategy instance, or by directly injecting the RouteReuseStrategy and casting it.

Key Considerations and Best Practices

  • Memory Management: Be selective about which components you cache. Avoid caching components with very large state or too many components, as this can lead to high memory consumption. Implement cache eviction strategies if necessary (e.g., max number of items, least recently used).
  • Data Freshness: Reattached components will show their previous data. If this data can become stale, implement logic (e.g., in onRouteReattach or via router events) to refresh it as needed.
  • Route Key Uniqueness: Ensure getRouteKey generates truly unique keys, especially if route parameters should lead to different cached instances (e.g., /user/1/profile vs. /user/2/profile). The default route.routeConfig.path might not be sufficient for such cases; you might need to incorporate parameters from route.params.
  • Interaction with Guards and Resolvers: Route guards (CanActivate, CanDeactivate, etc.) and resolvers typically run when a route is initially activated. They might not run again for a reattached component. Test thoroughly to ensure desired behavior. The (activate) event on <router-outlet> fires after guards and resolvers have passed for the initial activation and also when a component is reattached.
  • Debugging: Use console.log statements liberally within your strategy methods during development to understand the flow and decisions being made by the router. Browser developer tools and extensions like Angular DevTools can also help inspect component trees and router states.

When to Use a Custom RouteReuseStrategy

This strategy is particularly beneficial for:

  • Tabbed Interfaces: Preserving the state of each tab’s component as users switch between them.
  • Complex Search/Filter Pages: Keeping user-applied filters, sorting, and pagination intact when they navigate to a detail view and return.
  • Multi-Step Forms: Saving user input across steps, even if they temporarily navigate to another section of the app.
  • Master-Detail Views: Maintaining the scroll position and loaded data of a master list while viewing details.
  • Dashboards: Keeping the configuration and data of individual widgets on a dashboard.

Alternatives (and Their Limitations for This Problem)

  • Service-Based State Caching: Storing data in an injectable Angular service. This is good for data state but doesn’t preserve DOM state (scroll, form inputs) or the component instance itself.
  • State Management Libraries (e.g., NgRx, Akita, NGXS): Excellent for managing application-wide state, but like services, they don’t inherently preserve the live component instance or its DOM state. Components are still destroyed and recreated.

These alternatives are valuable for state management but do not solve the specific problem of preserving the entire component instance and its view state, which is the forte of RouteReuseStrategy.

Conclusion

Implementing a custom RouteReuseStrategy in Angular provides a robust solution for preserving complex component state across navigations. By carefully defining which components to cache and how to manage their lifecycle upon reattachment, developers can significantly enhance user experience, improve perceived performance, and build more intuitive applications. While it adds a layer of complexity to routing, the benefits for applications with stateful components are often well worth the effort. Remember to profile and test your implementation to ensure it meets your application’s specific needs regarding memory and data freshness.