Search code examples
angulartabslazy-loadingangular-routing

How to allow multiple tabs of the same component while keeping its state?


My app uses a lazy loading tab system that is managed with a service. When a user selects an option on the nav menu, two things happen :

  • In the tab service, an entry is added to the array of tabs.
  • A new route is activated and the appropriate component is loaded.

This works fine but I need something more complex :

  • Being able to click any tab and restore its state -> If forms have been filled, buttons have been clicked etc. I want the exact same state when I route back to my component. From what I understand, I think that implementing RouteReuseStrategy is best suited for this kind of behaviour.
  • Being able to open multiple instances of the same component. If my user selects the same option from the nav menu twice, I want to open two tabs. Each tab having its own state. I want to be able to restore that state when navigating to any of these tabs.

For now, the only solution that I found is to store the state of each component in session storage and update the components state when clicking on the tab. To achieve this, my components need to subscribe to NavigationStart and NavigationEnd events.

  • NavigationStart -> Save current state in session storage.
  • NavigationEnd -> Load current state from session storage.

This works fine for fairly simple components but for some very complex components containing dozens of buttons, form etc. It can quickly become a nightmare to manage since I have to write the loading and saving logic for each component. Session storage being limited in size, this is also a limit of this method.

Any suggestion would be very much appeciated.


Solution

  • For anyone running into the same issue, the sticky state can be achieved by implementing RouteReuseStrategy. You can find an example here.

    I thought that this approach would not work with multiple instances of the same component but it actually does. You just need to tweak the implementation a little bit :

    • Declare this.router.routeReuseStrategy.shouldReuseRoute = () => false; in the constuctor of your component so a new component is loaded even if the route doesn't change.
    • Stored routes are kept in a map. In the article above, the keys of this map are the different routes. I concatenated my tabId to the key so I can have different entries for the same route/component, depending on the tab I want to access. Example below :

    CacheRouteReuseStrategy

        @Injectable()
        export class CacheRouteReuseStrategy implements RouteReuseStrategy {
    
            constructor(private linkService : LinkService) {
            }
    
            shouldDetach(route: ActivatedRouteSnapshot): boolean {
                return true;
            }
    
            store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void {
                // If previous route and active route are the same, handle is null.
                // In that case, we don't want to update the store (we don't want to set a null object for the route and loose informations)
                if (handle != null && this.linkService.previousLink != null){
                    this.linkService.storedRoutes.set(route.routeConfig?.path + this.linkService.previousLink.tabId, handle);
                }
            }
    
            shouldAttach(route: ActivatedRouteSnapshot): boolean {
                if (this.linkService.activeLink != null) {
                    return !!route.routeConfig && !!this.linkService.storedRoutes.get(<string>route.routeConfig.path + this.linkService.activeLink.tabId);
                } else {
                    return false;
                }
            }
    
            retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle {
            // @ts-ignore
                return this.linkService.storedRoutes.get(route.routeConfig.path + this.linkService.activeLink.tabId);
            }
    
            shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
                return future.routeConfig === curr.routeConfig;
            }
    
        }
    

    I have a service called LinkService to keep track of the route I'm leaving (previousLink) and the route I'm trying to accesss (activeLink). previousLink is the object I want to add to the store when leaving a route and activeLink is the object I want to get from the store when accessing a route.

        @Injectable({
          providedIn: 'root'
        })
        export class LinkService {
          links: LinkModel[];
          activeLink: LinkModel;
          previousLink: LinkModel;
          storedRoutes = new Map<string, DetachedRouteHandle>();
        }
        
        export interface LinkModel {
          route: string,
          parameters: string | null,
          tabId: string,
          tabTitle: string
        }
    

    These informations are updated by another service, called TabService. What it does is pretty straightforward (create, change and remove tab logic) but I can post it later if anyone needs it.