In this blog post you’ll learn why you’d want and how to implement a lazy tab navigation system in Angular.
Why go for a lazy tab navigation system?
We at meshcloud provide a solution to manage cloud access, users and projects across cloud platforms. One of our main goals is to make the interaction with the cloud as easy as possible. Besides that we want to decrease the effort of management workflows to a minimum because cloud management is a complex topic with a lot of items to manage.
But how do we want to achieve this?
It is mostly a user facing topic. Therefore we’ve improved user experience in our Angular SPA (Single Page Application) and decreased the necessary interactions for specific management workflows. It is important to navigate between the sections without lagging or freezing.
It is really easy to say that the improvement of the user experience is the key. But what makes the difference to support user friendly behavior?
We’ve invested a lot of time to find a proper solution. We came up with a lazy tab navigation system is what we need which covers all mentioned challenges.
What is a lazy tab navigation system?
As the name suggests, the idea is a tab navigation system in which the content is loaded lazy. This will enable us to implement scalable views with a lot of clearly separated sections. But first to the general tab navigation system.
1. Tab navigation system
The tab navigation system is a central component which coordinates the tab creation and holds the tab state. We’ve decided to handle the tab creation based on the route config. This makes total sense because the routes are uniquely identifiable. This means we can automatically prevent the creation of multiple tabs which correspond to the same section. Besides that we have the possibility to create deep links to specific tab sections. This enables us to reference specific tab sections within other views. In general we’ll determine the tab state based on the activated route.
2. Lazy Loading
The other aspect is the lazy loading. Lazy loading is a central Angular feature. It is a design pattern that loads NgModules as needed. For comparison, there is the option to load NgModules eagerly. With eager loading all NgModules are loaded when accessing the web application. This increases the bundle size and for large applications it is not a best practice due to the load time. Eager loading is for a tab navigation system not an option because we’ve a lot of different sections. And we want to allow multiple sub-sections. This would mean that we load every section content up front. From the performance and UX perspective this is not a good approach.
If we now combine the tab navigation system approach with the lazy loading feature we’ll get a really flexible and scalable pattern. The tab navigation system takes the role of describing the context. It is kind of a container which defines the available routes and manages the tabs. In conjunction with the lazy loading feature, we are able to load the content of the respective tab in a dedicated manner.
What is necessary to implement the lazy tab navigation system?
We’ve got an overview about the lazy tab navigation system from the conceptual perspective. Now we’ll take a look at the technical realization and how we’ve implemented this approach.
We need a shared component which covers all criteria of the tab navigation system. This includes the tab creation based on the router config and tab selection based on the activated route. We call the component RouterOutletNavComponent.
Besides that we’ve defined an interface which describes each tab:
- displayName
- routerLink
- selected
Our interface declares additional properties. For example to show/hide a tab depending on a specific condition and to attach a badge with countable information. But this is not relevant for the general implementation. So we’ll leave it out for now.
We call the tab interface RouterOutletNavTab.
Sure, we didn’t implement the tab creation and tab selection logic within the component. We’ve implemented a service that exposes the functionality. This is a best practice to increase modularity and reusability. The component should only handle the user experience. With a clear separation we’ll also increase the testability of the functionalities. This approach should be followed every time 😉
We call the service RouterOutletNavService.
Now let's combine the RouterOutletNavComponent, RouterOutletNavTab and RouterOutletNavService. ****
@Component({
selector: 'mst-router-outlet-nav',
templateUrl: './router-outlet-nav.component.html',
styleUrls: ['./router-outlet-nav.component.scss']
})
export class RouterOutletNavComponent implements OnInit, OnDestroy {
@Input()
public readonly styleClass: RouterOutletNavStyleClass = 'nav-child';
public tabs: RouterOutletNavTab[];
private sub: Subscription;
constructor(
private readonly router: Router,
private readonly activatedRoute: ActivatedRoute,
private readonly routerOutletNavService: RouterOutletNavService
) {
this.tabs = this.routerOutletNavService.initializeTabs(this.activatedRoute);
}
ngOnInit() {
/**
* Select initial tab and navigate to child route.
*/
this.setupInitiallySelectedTab()
.subscribe((routerLink: string) => this.routerOutletNavService.selectTab(routerLink, this.tabs));
/**
* We listen to the router events to select the specific tab.
*/
this.sub = this.router.events
.pipe(
filter(x => x instanceof NavigationEnd),
switchMap((x: NavigationEnd) => {
/**
* If the firstChild is available then we don't determine the first child tab.
*/
if (this.activatedRoute.firstChild) {
return of(x.urlAfterRedirects);
}
/**
* If child route doesn't exists then we'll determine the child route and select the tab.
*/
return this.navigateToFirstChildRoute();
})
)
.subscribe({
next: (routerLink: string) => this.routerOutletNavService.selectTab(routerLink, this.tabs)
});
}
ngOnDestroy(): void {
this.sub.unsubscribe();
}
private setupInitiallySelectedTab(): Observable<string> {
/**
* If childs are applied for example in case of redirection then we'll use the existing destination url.
*/
const currentUrl = this.router.url;
if (this.activatedRoute.firstChild) {
return of(currentUrl);
}
return this.navigateToFirstChildRoute();
}
private navigateToFirstChildRoute(): Observable<string> {
return this.routerOutletNavService.findFirstChildRoute(this.tabs)
.pipe(
take(1),
/**
* This side effect is necessary to apply the determined tab route.
*/
tap((routerLink: string) => {
const extras = {
relativeTo: this.activatedRoute,
replaceUrl: true
};
this.router.navigate(['./', routerLink], extras);
})
);
}
}
We want to support not only a tab navigation system. We want to support a lazy tab navigation system. So it is necessary to embed a RouterOutlet into the tab container. For the styling we use Bootstrap as external dependency.
The corresponding HTML file would look like this:
<div [ngClass]="styleClass">
<ul class="container nav nav-tabs">
<ng-container *ngFor="let t of tabs">
<li class="nav-item">
<a class="nav-link" [class.active]="t.selected" [routerLink]="[t.routerLink]" [id]="t.routerLink">
{{t.displayName}}
</a>
</li>
</ng-container>
</ul>
<div class="tab-content p-4">
<router-outlet></router-outlet>
</div>
</div>
It is a valid use case to nest multiple lazy tab navigation systems. But for simplification we allow only one additional layer. Therefore we’ve defined two different css classes ‘nav-root’ and ‘nav-child’ to tell the levels apart.
We call the style class type RouterOutletNavStyleClass.
export type RouterOutletNavStyleClass = 'nav-root' | 'nav-child';
Very well done. We've implemented our shared lazy tab navigation system.
Now we have to feed the tab navigation system with the corresponding data. The information about the router link and if a tab is selected will be determined based on the route config and activated route. It is really important to declare the available routes with the lazy loading approach from Angular.
But how do we get the display name of each tab? Sure, we could use the route path name. But the name is our uniquely identifiable name. It could also be a human unreadable string. From the user perspective, it is not a good approach to use an identifier string as tab display name. Besides that we need a way to determine some data up front. Keep in mind, besides the display name we’ve also a condition and badge information in place. So we need the possibility to attach specific session data to each tab. Therefore we need to declare a resolver which returns a RouterOutletNavSessionData observable.
We call the abstract resolver class RouterOutletNavSessionResolver.
export interface RouterOutletNavTabData {
displayName: string;
}
export interface RouterOutletNavSessionData {
[key: string]: RouterOutletNavTabData;
}
export abstract class RouterOutletNavSessionResolver implements Resolve<RouterOutletNavSessionData> {
abstract resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RouterOutletNavSessionData>;
}
Now we’ve everything in place to use the lazy tab navigation system within a specific use case.
Lazy tab navigation system in action
At this point it makes sense to apply the lazy tab navigation system to one of our use cases. We’ve mentioned at the beginning that we provide a solution to manage cloud tenants. This includes for example the customer management. Within a customer we can manage projects, users, financials and much more. So it makes total sense to use the lazy tab navigation system with one additional level to make the management as easy as possible.
We’ll only consider the customer root level. The nested levels are out of scope for now. But in general they follow the same approach.
Our top level component would be the CustomerManagementComponent. This component declares the RouterOutletNavComponent within the HTML file and applies the ‘nav-root’ style class.
...
<mst-router-outlet-nav styleClass="nav-root"></mst-router-outlet-nav>
...
Then we’ll add all available routes to the CustomerManagementRoutingModule. Besides that, it's important to add a CustomerManagementNavSessionResolver to determine the session data up front. This provides our basis for the tab creation.
@Injectable({
providedIn: 'root'
})
export class CustomerManagementNavSessionResolver extends RouterOutletNavSessionResolver {
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RouterOutletNavSessionData> {
const sessionData = {} as RouterOutletNavSessionData;
sessionData['projects'] = this.getProjectsNavTabData();
sessionData['access-control'] = this.getAccessControlNavTabData();
sessionData['financials'] = this.getFinancialsNavTabData();
...
return of(sessionData);
}
private getProjectsNavTabData(): RouterOutletNavTabData {
return {
displayName: 'Projects'
};
}
private getAccessControlNavTabData(): RouterOutletNavTabData {
return {
displayName: 'Access Control'
};
}
private getFinancialsNavTabData(): RouterOutletNavTabData {
return {
displayName: 'Financials'
};
}
...
}
const routes: Routes = [
{
path: '',
component: CustomerManagementComponent,
resolve: {
session: CustomerManagementNavSessionResolver
},
children: [
{
path: 'projects',
loadChildren: () => import('./projects').then(m => ...)
},
{
path: 'access-control',
loadChildren: () => import('./access-control').then(m => ...)
},
{
path: 'financials',
loadChildren: () => import('./customer-financials').then(m => ...)
},
...
]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class CustomerManagementRoutingModule { }
That's it. No additional steps are necessary. We can easily add tabs and nested lazy tab navigation systems to the CustomerManagementComponent. For example we could think about a settings section or projects overview section. There are no limits to the imagination.
As inspiration our customer management view looks like this: