Routing and Navigation in Angular¶
Course Objective¶
Master Angular's routing system to build sophisticated single-page applications with navigation, route guards, lazy loading, and advanced routing patterns.
1. Introduction to Angular Routing¶
Angular's Router enables navigation between different views in a single-page application. It manages the browser's URL and maps it to components, providing a seamless user experience.
Key Concepts¶
- Route: A mapping between a URL path and a component
- Router Outlet: A placeholder for routed components
- Router: The service that manages navigation
- ActivatedRoute: Information about the active route
- Route Guards: Control access to routes
- Lazy Loading: Load modules on demand
Benefits¶
- SEO-friendly URLs
- Browser history support
- Bookmarkable pages
- Code splitting with lazy loading
- Navigation guards for security
2. Setting Up Routing¶
Modern Setup (Standalone Components)¶
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
// other providers
]
});
// app/app.routes.ts
import { Routes } from '@angular/router';
import { HomeComponent } from './components/home.component';
import { AboutComponent } from './components/about.component';
import { ContactComponent } from './components/contact.component';
export const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'about', component: AboutComponent },
{ path: 'contact', component: ContactComponent },
{ path: '**', redirectTo: '' } // Wildcard route - must be last
];
Traditional Module Setup¶
// app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './components/home.component';
import { AboutComponent } from './components/about.component';
const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'about', component: AboutComponent },
{ path: '**', redirectTo: '' }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
AppRoutingModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
App Component Template¶
// app.component.ts
import { Component } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<nav class="navbar">
<div class="nav-brand">
<a routerLink="/" class="brand-link">MyApp</a>
</div>
<ul class="nav-links">
<li>
<a routerLink="/"
routerLinkActive="active"
[routerLinkActiveOptions]="{exact: true}">
Home
</a>
</li>
<li>
<a routerLink="/about"
routerLinkActive="active">
About
</a>
</li>
<li>
<a routerLink="/contact"
routerLinkActive="active">
Contact
</a>
</li>
</ul>
</nav>
<main class="main-content">
<router-outlet></router-outlet>
</main>
`,
styles: [`
.navbar {
display: flex;
justify-content: space-between;
padding: 1rem;
background-color: #333;
color: white;
}
.nav-links {
display: flex;
list-style: none;
gap: 1rem;
margin: 0;
padding: 0;
}
.nav-links a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.3s;
}
.nav-links a:hover,
.nav-links a.active {
background-color: #555;
}
.main-content {
padding: 2rem;
}
`]
})
export class AppComponent {
title = 'my-app';
}
3. Basic Navigation¶
Declarative Navigation with RouterLink¶
// components/navigation.component.ts
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-navigation',
standalone: true,
imports: [RouterLink, RouterLinkActive],
template: `
<nav class="navigation">
<!-- Simple navigation -->
<a routerLink="/home">Home</a>
<a routerLink="/products">Products</a>
<a routerLink="/about">About</a>
<!-- Navigation with parameters -->
<a [routerLink]="['/user', userId]">My Profile</a>
<a [routerLink]="['/product', productId]">Product Details</a>
<!-- Navigation with query parameters -->
<a [routerLink]="['/search']"
[queryParams]="{q: 'angular', category: 'tech'}">
Search Angular
</a>
<!-- Navigation with fragments -->
<a [routerLink]="['/docs']"
fragment="routing">
Routing Section
</a>
<!-- Conditional navigation -->
<a *ngIf="isLoggedIn"
routerLink="/dashboard"
routerLinkActive="active">
Dashboard
</a>
<!-- Preserve query parameters -->
<a [routerLink]="['/profile']"
queryParamsHandling="preserve">
Profile (preserve params)
</a>
</nav>
`
})
export class NavigationComponent {
userId = 123;
productId = 456;
isLoggedIn = true;
}
Programmatic Navigation with Router¶
// components/search.component.ts
import { Component } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-search',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<div class="search-container">
<input
[formControl]="searchControl"
placeholder="Search..."
(keyup.enter)="search()"
>
<button (click)="search()">Search</button>
<button (click)="goBack()">Back</button>
<button (click)="goToAdvancedSearch()">Advanced Search</button>
</div>
`
})
export class SearchComponent {
searchControl = new FormControl('');
constructor(
private router: Router,
private route: ActivatedRoute
) {}
search(): void {
const query = this.searchControl.value;
if (query) {
// Navigate with query parameters
this.router.navigate(['/search-results'], {
queryParams: { q: query, page: 1 }
});
}
}
goBack(): void {
// Navigate back in history
window.history.back();
// Or use Location service:
// this.location.back();
}
goToAdvancedSearch(): void {
// Navigate relative to current route
this.router.navigate(['../advanced'], {
relativeTo: this.route,
queryParams: { mode: 'advanced' }
});
}
navigateWithOptions(): void {
this.router.navigate(['/results'], {
queryParams: { search: 'angular' },
fragment: 'top',
queryParamsHandling: 'merge', // merge with existing params
skipLocationChange: false, // update URL
replaceUrl: false // don't replace current URL in history
});
}
}
4. Route Parameters¶
Defining Routes with Parameters¶
// app.routes.ts
export const routes: Routes = [
{ path: 'user/:id', component: UserComponent },
{ path: 'user/:id/edit', component: EditUserComponent },
{ path: 'product/:categoryId/:productId', component: ProductComponent },
{ path: 'search', component: SearchComponent }, // Query params
{ path: '**', redirectTo: '' }
];
Accessing Route Parameters¶
// components/user.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { takeUntil, switchMap } from 'rxjs/operators';
import { UserService } from '../services/user.service';
@Component({
selector: 'app-user',
template: `
<div class="user-profile" *ngIf="user">
<h1>{{ user.name }}</h1>
<p>{{ user.email }}</p>
<button (click)="editUser()">Edit</button>
<button (click)="goToNextUser()">Next User</button>
</div>
<div *ngIf="loading">Loading user...</div>
<div *ngIf="error">{{ error }}</div>
`
})
export class UserComponent implements OnInit, OnDestroy {
user: User | null = null;
loading = false;
error: string | null = null;
private destroy$ = new Subject<void>();
constructor(
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {}
ngOnInit(): void {
// Method 1: Using paramMap observable (recommended)
this.route.paramMap
.pipe(
takeUntil(this.destroy$),
switchMap((params: ParamMap) => {
const id = Number(params.get('id'));
this.loading = true;
this.error = null;
return this.userService.getUser(id);
})
)
.subscribe({
next: (user) => {
this.user = user;
this.loading = false;
},
error: (error) => {
this.error = 'Failed to load user';
this.loading = false;
console.error('Error loading user:', error);
}
});
// Method 2: Snapshot (for one-time access)
// const id = Number(this.route.snapshot.paramMap.get('id'));
// this.loadUser(id);
// Access query parameters
this.route.queryParamMap
.pipe(takeUntil(this.destroy$))
.subscribe(queryParams => {
const tab = queryParams.get('tab');
const edit = queryParams.get('edit') === 'true';
// Handle query parameters
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
editUser(): void {
if (this.user) {
this.router.navigate(['/user', this.user.id, 'edit']);
}
}
goToNextUser(): void {
if (this.user) {
const nextId = this.user.id + 1;
this.router.navigate(['/user', nextId]);
}
}
}
Working with Query Parameters¶
// components/search-results.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Subject, combineLatest } from 'rxjs';
import { takeUntil, map, debounceTime, distinctUntilChanged } from 'rxjs/operators';
@Component({
selector: 'app-search-results',
template: `
<div class="search-results">
<div class="search-header">
<h2>Search Results for "{{ searchQuery }}"</h2>
<div class="filters">
<select (change)="updateCategory($event)">
<option value="">All Categories</option>
<option value="tech">Technology</option>
<option value="business">Business</option>
</select>
<input
type="number"
[value]="currentPage"
(change)="updatePage($event)"
min="1"
>
</div>
</div>
<div class="results">
<div *ngFor="let result of results" class="result-item">
{{ result.title }}
</div>
</div>
<div class="pagination">
<button
[disabled]="currentPage <= 1"
(click)="previousPage()">
Previous
</button>
<span>Page {{ currentPage }} of {{ totalPages }}</span>
<button
[disabled]="currentPage >= totalPages"
(click)="nextPage()">
Next
</button>
</div>
</div>
`
})
export class SearchResultsComponent implements OnInit, OnDestroy {
searchQuery = '';
category = '';
currentPage = 1;
totalPages = 1;
results: any[] = [];
private destroy$ = new Subject<void>();
constructor(
private route: ActivatedRoute,
private router: Router,
private searchService: SearchService
) {}
ngOnInit(): void {
// Listen to both route params and query params
combineLatest([
this.route.paramMap,
this.route.queryParamMap
]).pipe(
takeUntil(this.destroy$),
map(([params, queryParams]) => ({
query: queryParams.get('q') || '',
category: queryParams.get('category') || '',
page: Number(queryParams.get('page')) || 1
})),
debounceTime(100), // Prevent rapid updates
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
).subscribe(({ query, category, page }) => {
this.searchQuery = query;
this.category = category;
this.currentPage = page;
this.performSearch();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private performSearch(): void {
this.searchService.search({
query: this.searchQuery,
category: this.category,
page: this.currentPage
}).subscribe(response => {
this.results = response.results;
this.totalPages = response.totalPages;
});
}
updateCategory(event: any): void {
const category = event.target.value;
this.updateQueryParams({ category, page: 1 });
}
updatePage(event: any): void {
const page = Number(event.target.value);
this.updateQueryParams({ page });
}
previousPage(): void {
if (this.currentPage > 1) {
this.updateQueryParams({ page: this.currentPage - 1 });
}
}
nextPage(): void {
if (this.currentPage < this.totalPages) {
this.updateQueryParams({ page: this.currentPage + 1 });
}
}
private updateQueryParams(params: any): void {
this.router.navigate([], {
relativeTo: this.route,
queryParams: params,
queryParamsHandling: 'merge'
});
}
}
5. Nested Routes¶
Defining Nested Routes¶
// app.routes.ts
export const routes: Routes = [
{
path: 'dashboard',
component: DashboardComponent,
children: [
{ path: '', component: DashboardHomeComponent },
{ path: 'analytics', component: AnalyticsComponent },
{ path: 'reports', component: ReportsComponent },
{ path: 'settings', component: SettingsComponent }
]
},
{
path: 'admin',
component: AdminLayoutComponent,
children: [
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
{ path: 'overview', component: AdminOverviewComponent },
{
path: 'users',
component: UserManagementComponent,
children: [
{ path: '', component: UserListComponent },
{ path: ':id', component: UserDetailComponent },
{ path: ':id/edit', component: EditUserComponent }
]
}
]
}
];
Parent Component with Router Outlet¶
// components/dashboard.component.ts
import { Component } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<div class="dashboard-layout">
<aside class="sidebar">
<nav class="sidebar-nav">
<a routerLink="/dashboard"
routerLinkActive="active"
[routerLinkActiveOptions]="{exact: true}">
Home
</a>
<a routerLink="/dashboard/analytics"
routerLinkActive="active">
Analytics
</a>
<a routerLink="/dashboard/reports"
routerLinkActive="active">
Reports
</a>
<a routerLink="/dashboard/settings"
routerLinkActive="active">
Settings
</a>
</nav>
</aside>
<main class="main-content">
<header class="content-header">
<h1>Dashboard</h1>
<div class="user-info">
Welcome, {{ userName }}
</div>
</header>
<!-- Nested routes rendered here -->
<router-outlet></router-outlet>
</main>
</div>
`,
styles: [`
.dashboard-layout {
display: flex;
height: 100vh;
}
.sidebar {
width: 250px;
background-color: #2c3e50;
color: white;
}
.sidebar-nav {
display: flex;
flex-direction: column;
padding: 1rem 0;
}
.sidebar-nav a {
color: white;
text-decoration: none;
padding: 1rem 1.5rem;
transition: background-color 0.3s;
}
.sidebar-nav a:hover,
.sidebar-nav a.active {
background-color: #34495e;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
border-bottom: 1px solid #eee;
}
`]
})
export class DashboardComponent {
userName = 'John Doe';
}
Accessing Parent Route Data¶
// components/user-detail.component.ts
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { map, switchMap } from 'rxjs/operators';
@Component({
selector: 'app-user-detail',
template: `
<div class="user-detail">
<div class="breadcrumb">
<a [routerLink]="['/admin']">Admin</a> >
<a [routerLink]="['/admin/users']">Users</a> >
<span>{{ user?.name }}</span>
</div>
<div *ngIf="user" class="user-info">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
<button [routerLink]="['edit']">Edit User</button>
</div>
</div>
`
})
export class UserDetailComponent implements OnInit {
user: User | null = null;
constructor(private route: ActivatedRoute) {}
ngOnInit(): void {
// Access nested route parameters
this.route.paramMap.pipe(
map(params => Number(params.get('id'))),
switchMap(id => this.userService.getUser(id))
).subscribe(user => {
this.user = user;
});
// Access parent route data
this.route.parent?.data.subscribe(parentData => {
console.log('Parent route data:', parentData);
});
// Access root route data
this.route.root.data.subscribe(rootData => {
console.log('Root route data:', rootData);
});
}
}
6. Route Guards¶
CanActivate Guard¶
// guards/auth.guard.ts
import { Injectable } from '@angular/core';
import { CanActivate, Router, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
import { map, take } from 'rxjs/operators';
import { AuthService } from '../services/auth.service';
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
constructor(
private authService: AuthService,
private router: Router
) {}
canActivate(): Observable<boolean | UrlTree> {
return this.authService.isAuthenticated$.pipe(
take(1),
map(isAuthenticated => {
if (isAuthenticated) {
return true;
} else {
// Redirect to login page
return this.router.createUrlTree(['/login']);
}
})
);
}
}
CanActivateChild Guard¶
// guards/admin.guard.ts
import { Injectable } from '@angular/core';
import { CanActivateChild, Router, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AuthService } from '../services/auth.service';
@Injectable({
providedIn: 'root'
})
export class AdminGuard implements CanActivateChild {
constructor(
private authService: AuthService,
private router: Router
) {}
canActivateChild(): Observable<boolean | UrlTree> {
return this.authService.user$.pipe(
map(user => {
if (user && user.role === 'admin') {
return true;
} else {
return this.router.createUrlTree(['/unauthorized']);
}
})
);
}
}
CanDeactivate Guard¶
// guards/can-deactivate.guard.ts
import { Injectable } from '@angular/core';
import { CanDeactivate } from '@angular/router';
import { Observable } from 'rxjs';
export interface CanComponentDeactivate {
canDeactivate(): Observable<boolean> | Promise<boolean> | boolean;
}
@Injectable({
providedIn: 'root'
})
export class CanDeactivateGuard implements CanDeactivate<CanComponentDeactivate> {
canDeactivate(
component: CanComponentDeactivate
): Observable<boolean> | Promise<boolean> | boolean {
return component.canDeactivate ? component.canDeactivate() : true;
}
}
// Using in a component
@Component({...})
export class EditFormComponent implements CanComponentDeactivate {
@HostListener('window:beforeunload', ['$event'])
unloadNotification($event: BeforeUnloadEvent): void {
if (this.hasUnsavedChanges) {
$event.returnValue = 'You have unsaved changes. Are you sure you want to leave?';
}
}
canDeactivate(): boolean {
if (this.hasUnsavedChanges) {
return confirm('You have unsaved changes. Are you sure you want to leave?');
}
return true;
}
private get hasUnsavedChanges(): boolean {
return this.form.dirty && !this.form.submitted;
}
}
Resolve Guard¶
// guards/user-resolver.guard.ts
import { Injectable } from '@angular/core';
import { Resolve, ActivatedRouteSnapshot } from '@angular/router';
import { Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { UserService } from '../services/user.service';
@Injectable({
providedIn: 'root'
})
export class UserResolver implements Resolve<User | null> {
constructor(private userService: UserService) {}
resolve(route: ActivatedRouteSnapshot): Observable<User | null> {
const id = Number(route.paramMap.get('id'));
return this.userService.getUser(id).pipe(
catchError(error => {
console.error('Error loading user:', error);
return of(null);
})
);
}
}
Applying Guards to Routes¶
// app.routes.ts
export const routes: Routes = [
{
path: 'dashboard',
component: DashboardComponent,
canActivate: [AuthGuard],
children: [
{ path: '', component: DashboardHomeComponent },
{ path: 'analytics', component: AnalyticsComponent }
]
},
{
path: 'admin',
component: AdminLayoutComponent,
canActivate: [AuthGuard],
canActivateChild: [AdminGuard],
children: [
{
path: 'user/:id',
component: UserDetailComponent,
resolve: { user: UserResolver }
}
]
},
{
path: 'edit-profile',
component: EditProfileComponent,
canActivate: [AuthGuard],
canDeactivate: [CanDeactivateGuard]
}
];
7. Lazy Loading¶
Feature Module Setup¶
// features/products/products.routes.ts
import { Routes } from '@angular/router';
import { ProductListComponent } from './components/product-list.component';
import { ProductDetailComponent } from './components/product-detail.component';
export const PRODUCTS_ROUTES: Routes = [
{ path: '', component: ProductListComponent },
{ path: ':id', component: ProductDetailComponent }
];
Main Routes with Lazy Loading¶
// app.routes.ts
export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'products',
loadChildren: () => import('./features/products/products.routes').then(m => m.PRODUCTS_ROUTES)
},
{
path: 'admin',
loadChildren: () => import('./features/admin/admin.routes').then(m => m.ADMIN_ROUTES),
canActivate: [AuthGuard]
},
{
path: 'user',
loadComponent: () => import('./features/user/user.component').then(m => m.UserComponent)
},
{ path: '**', redirectTo: '' }
];
Preloading Strategies¶
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(
routes,
withPreloading(PreloadAllModules) // Preload all lazy modules
)
]
});
Custom Preloading Strategy¶
// services/custom-preloading.service.ts
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class CustomPreloadingStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
// Only preload routes marked with preload: true
if (route.data && route.data['preload']) {
console.log('Preloading: ' + route.path);
return load();
} else {
return of(null);
}
}
}
// Using custom preloading strategy
export const routes: Routes = [
{
path: 'products',
loadChildren: () => import('./features/products/products.routes').then(m => m.PRODUCTS_ROUTES),
data: { preload: true } // This route will be preloaded
},
{
path: 'admin',
loadChildren: () => import('./features/admin/admin.routes').then(m => m.ADMIN_ROUTES),
data: { preload: false } // This route will not be preloaded
}
];
8. Advanced Routing Patterns¶
Route Data and Custom Properties¶
// app.routes.ts
export const routes: Routes = [
{
path: 'dashboard',
component: DashboardComponent,
data: {
title: 'Dashboard',
breadcrumb: 'Dashboard',
roles: ['user', 'admin'],
animation: 'slide'
}
},
{
path: 'admin',
component: AdminComponent,
data: {
title: 'Administration',
breadcrumb: 'Admin',
roles: ['admin'],
hideNavigation: true
}
}
];
// Accessing route data
@Component({...})
export class BaseComponent implements OnInit {
constructor(private route: ActivatedRoute) {}
ngOnInit(): void {
this.route.data.subscribe(data => {
document.title = data['title'] || 'Default Title';
this.showNavigation = !data['hideNavigation'];
});
}
}
Dynamic Route Configuration¶
// services/dynamic-routes.service.ts
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { ComponentType } from '@angular/cdk/portal';
@Injectable({
providedIn: 'root'
})
export class DynamicRoutesService {
constructor(private router: Router) {}
addRoute(path: string, component: ComponentType<any>): void {
const config = this.router.config;
config.unshift({ path, component });
this.router.resetConfig(config);
}
removeRoute(path: string): void {
const config = this.router.config.filter(route => route.path !== path);
this.router.resetConfig(config);
}
addPluginRoutes(pluginRoutes: any[]): void {
const config = [...pluginRoutes, ...this.router.config];
this.router.resetConfig(config);
}
}
Route Animations¶
// animations/route-animations.ts
import { trigger, transition, style, query, animateChild, group, animate } from '@angular/animations';
export const routeAnimations = trigger('routeAnimations', [
transition('* <=> *', [
query(':enter, :leave', [
style({
position: 'absolute',
top: 0,
left: 0,
width: '100%'
})
], { optional: true }),
query(':enter', [
style({ opacity: 0, transform: 'translateX(100%)' })
], { optional: true }),
group([
query(':leave', [
animate('300ms ease-out', style({ opacity: 0, transform: 'translateX(-100%)' }))
], { optional: true }),
query(':enter', [
animate('300ms ease-out', style({ opacity: 1, transform: 'translateX(0%)' }))
], { optional: true })
])
])
]);
// app.component.ts
@Component({
selector: 'app-root',
template: `
<div [@routeAnimations]="prepareRoute(outlet)">
<router-outlet #outlet="outlet"></router-outlet>
</div>
`,
animations: [routeAnimations]
})
export class AppComponent {
prepareRoute(outlet: RouterOutlet) {
return outlet && outlet.activatedRouteData && outlet.activatedRouteData['animation'];
}
}
9. Router Events and Navigation¶
Listening to Router Events¶
// services/navigation.service.ts
import { Injectable } from '@angular/core';
import {
Router,
NavigationStart,
NavigationEnd,
NavigationCancel,
NavigationError,
RoutesRecognized
} from '@angular/router';
import { filter } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class NavigationService {
constructor(private router: Router) {
this.setupRouterEventListeners();
}
private setupRouterEventListeners(): void {
// Listen to navigation start
this.router.events.pipe(
filter(event => event instanceof NavigationStart)
).subscribe((event: NavigationStart) => {
console.log('Navigation started to:', event.url);
this.showLoadingIndicator();
});
// Listen to navigation end
this.router.events.pipe(
filter(event => event instanceof NavigationEnd)
).subscribe((event: NavigationEnd) => {
console.log('Navigation ended:', event.url);
this.hideLoadingIndicator();
this.trackPageView(event.url);
});
// Listen to navigation errors
this.router.events.pipe(
filter(event => event instanceof NavigationError)
).subscribe((event: NavigationError) => {
console.error('Navigation error:', event.error);
this.handleNavigationError(event);
});
// Listen to route recognition
this.router.events.pipe(
filter(event => event instanceof RoutesRecognized)
).subscribe((event: RoutesRecognized) => {
console.log('Routes recognized:', event.state.root);
});
}
private showLoadingIndicator(): void {
// Show global loading indicator
}
private hideLoadingIndicator(): void {
// Hide global loading indicator
}
private trackPageView(url: string): void {
// Analytics tracking
// gtag('config', 'GA_TRACKING_ID', { page_path: url });
}
private handleNavigationError(event: NavigationError): void {
// Log error to monitoring service
// Redirect to error page if needed
this.router.navigate(['/error'], { queryParams: { error: 'navigation' } });
}
}
Loading Indicator Component¶
// components/loading-indicator.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Router, NavigationStart, NavigationEnd, NavigationCancel, NavigationError } from '@angular/router';
import { Subject } from 'rxjs';
import { takeUntil, filter } from 'rxjs/operators';
@Component({
selector: 'app-loading-indicator',
template: `
<div class="loading-bar" [class.active]="loading">
<div class="loading-progress"></div>
</div>
`,
styles: [`
.loading-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
background-color: #f0f0f0;
z-index: 9999;
opacity: 0;
transition: opacity 0.3s;
}
.loading-bar.active {
opacity: 1;
}
.loading-progress {
height: 100%;
background: linear-gradient(90deg, #007bff, #0056b3);
width: 0%;
animation: loading 2s linear infinite;
}
@keyframes loading {
0% { width: 0%; }
50% { width: 70%; }
100% { width: 100%; }
}
`]
})
export class LoadingIndicatorComponent implements OnInit, OnDestroy {
loading = false;
private destroy$ = new Subject<void>();
constructor(private router: Router) {}
ngOnInit(): void {
this.router.events
.pipe(
takeUntil(this.destroy$),
filter(event =>
event instanceof NavigationStart ||
event instanceof NavigationEnd ||
event instanceof NavigationCancel ||
event instanceof NavigationError
)
)
.subscribe(event => {
if (event instanceof NavigationStart) {
this.loading = true;
} else {
this.loading = false;
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
10. Testing Routing¶
Testing Router Navigation¶
// components/navigation.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { Location } from '@angular/common';
import { Component } from '@angular/core';
import { RouterTestingModule } from '@angular/router/testing';
import { NavigationComponent } from './navigation.component';
@Component({ template: '' })
class DummyComponent { }
describe('NavigationComponent', () => {
let component: NavigationComponent;
let fixture: ComponentFixture<NavigationComponent>;
let router: Router;
let location: Location;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [NavigationComponent],
imports: [
RouterTestingModule.withRoutes([
{ path: 'home', component: DummyComponent },
{ path: 'about', component: DummyComponent },
{ path: 'contact', component: DummyComponent }
])
]
}).compileComponents();
fixture = TestBed.createComponent(NavigationComponent);
component = fixture.componentInstance;
router = TestBed.inject(Router);
location = TestBed.inject(Location);
fixture.detectChanges();
});
it('should navigate to home', async () => {
await router.navigate(['/home']);
expect(location.path()).toBe('/home');
});
it('should navigate to about page when button clicked', async () => {
const aboutButton = fixture.debugElement.nativeElement.querySelector('[data-test="about-button"]');
aboutButton.click();
await fixture.whenStable();
expect(location.path()).toBe('/about');
});
});
Testing Route Guards¶
// guards/auth.guard.spec.ts
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { AuthGuard } from './auth.guard';
import { AuthService } from '../services/auth.service';
import { of } from 'rxjs';
describe('AuthGuard', () => {
let guard: AuthGuard;
let authService: jasmine.SpyObj<AuthService>;
let router: jasmine.SpyObj<Router>;
beforeEach(() => {
const authServiceSpy = jasmine.createSpyObj('AuthService', [], {
isAuthenticated$: of(false)
});
const routerSpy = jasmine.createSpyObj('Router', ['createUrlTree']);
TestBed.configureTestingModule({
providers: [
AuthGuard,
{ provide: AuthService, useValue: authServiceSpy },
{ provide: Router, useValue: routerSpy }
]
});
guard = TestBed.inject(AuthGuard);
authService = TestBed.inject(AuthService) as jasmine.SpyObj<AuthService>;
router = TestBed.inject(Router) as jasmine.SpyObj<Router>;
});
it('should allow access when user is authenticated', (done) => {
(Object.getOwnPropertyDescriptor(authService, 'isAuthenticated$')!.get as jasmine.Spy).and.returnValue(of(true));
guard.canActivate().subscribe(result => {
expect(result).toBe(true);
done();
});
});
it('should redirect to login when user is not authenticated', (done) => {
const urlTree = router.createUrlTree(['/login']);
router.createUrlTree.and.returnValue(urlTree);
guard.canActivate().subscribe(result => {
expect(result).toBe(urlTree);
expect(router.createUrlTree).toHaveBeenCalledWith(['/login']);
done();
});
});
});
Testing Route Parameters¶
// components/user.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { UserComponent } from './user.component';
import { UserService } from '../services/user.service';
describe('UserComponent', () => {
let component: UserComponent;
let fixture: ComponentFixture<UserComponent>;
let userService: jasmine.SpyObj<UserService>;
beforeEach(async () => {
const userServiceSpy = jasmine.createSpyObj('UserService', ['getUser']);
await TestBed.configureTestingModule({
declarations: [UserComponent],
providers: [
{ provide: UserService, useValue: userServiceSpy },
{
provide: ActivatedRoute,
useValue: {
paramMap: of(new Map([['id', '123']])),
queryParamMap: of(new Map([['tab', 'profile']]))
}
}
]
}).compileComponents();
fixture = TestBed.createComponent(UserComponent);
component = fixture.componentInstance;
userService = TestBed.inject(UserService) as jasmine.SpyObj<UserService>;
const mockUser = { id: 123, name: 'John Doe', email: 'john@example.com' };
userService.getUser.and.returnValue(of(mockUser));
fixture.detectChanges();
});
it('should load user based on route parameter', () => {
expect(userService.getUser).toHaveBeenCalledWith(123);
expect(component.user).toEqual({ id: 123, name: 'John Doe', email: 'john@example.com' });
});
});
11. Best Practices¶
Route Organization¶
// ✅ Good: Organized route structure
export const routes: Routes = [
// Public routes
{ path: '', component: HomeComponent },
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
// Protected routes
{
path: 'app',
canActivate: [AuthGuard],
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent },
{ path: 'profile', component: ProfileComponent }
]
},
// Feature modules (lazy loaded)
{
path: 'products',
loadChildren: () => import('./features/products/products.module').then(m => m.ProductsModule)
},
// Error routes
{ path: '404', component: NotFoundComponent },
{ path: 'error', component: ErrorComponent },
// Wildcard route (must be last)
{ path: '**', redirectTo: '404' }
];
Memory Management¶
// ✅ Good: Proper subscription management
@Component({...})
export class RouteAwareComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
constructor(private route: ActivatedRoute) {}
ngOnInit(): void {
// Use takeUntil for automatic unsubscription
this.route.paramMap
.pipe(takeUntil(this.destroy$))
.subscribe(params => {
// Handle parameters
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
Performance Optimization¶
// ✅ Use OnPush change detection with async pipe
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div *ngIf="user$ | async as user">
{{ user.name }}
</div>
`
})
export class OptimizedUserComponent {
user$ = this.route.paramMap.pipe(
map(params => Number(params.get('id'))),
switchMap(id => this.userService.getUser(id)),
shareReplay(1)
);
constructor(
private route: ActivatedRoute,
private userService: UserService
) {}
}
Common Pitfalls to Avoid¶
- Not handling route parameters reactively - Use paramMap observable, not snapshot
- Memory leaks from subscriptions - Always unsubscribe in ngOnDestroy
- Wildcard route placement - Always place wildcard routes last
- Not implementing proper loading states - Show loading indicators during navigation
- Ignoring error handling - Handle navigation errors gracefully
- Not using route guards - Protect routes that require authentication
- Poor route organization - Group related routes and use feature modules
Next Steps¶
After mastering Angular routing, you'll be ready to explore:
- Forms: Build complex forms with validation
- State Management: Manage application state with NgRx or Akita
- Performance Optimization: Advanced optimization techniques
- Testing: Comprehensive testing strategies
Routing is essential for creating professional Angular applications. Practice these patterns to build robust, user-friendly navigation systems!