feat: add Angular NgRx best practices documentation

This commit is contained in:
Dennis Hundertmark
2026-03-08 08:51:02 +01:00
parent 67dc823270
commit 2184971175
47 changed files with 8490 additions and 0 deletions
+394
View File
@@ -0,0 +1,394 @@
---
name: angular-routing
description: Implement routing in Angular v20+ applications with lazy loading, functional guards, resolvers, and route parameters. Use for navigation setup, protected routes, route-based data loading, and nested routing. Triggers on route configuration, adding authentication guards, implementing lazy loading, or reading route parameters with signals.
---
# Angular Routing
Configure routing in Angular v20+ with lazy loading, functional guards, and signal-based route parameters.
## Basic Setup
```typescript
// app.routes.ts
import { Routes } from "@angular/router";
export const routes: Routes = [
{ path: "", redirectTo: "/home", pathMatch: "full" },
{ path: "home", component: Home },
{ path: "about", component: About },
{ path: "**", component: NotFound },
];
// app.config.ts
import { ApplicationConfig } from "@angular/core";
import { provideRouter } from "@angular/router";
import { routes } from "./app.routes";
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes)],
};
// app.component.ts
import { Component } from "@angular/core";
import { RouterOutlet, RouterLink, RouterLinkActive } from "@angular/router";
@Component({
selector: "app-root",
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<nav>
<a routerLink="/home" routerLinkActive="active">Home</a>
<a routerLink="/about" routerLinkActive="active">About</a>
</nav>
<router-outlet />
`,
})
export class App {}
```
## Lazy Loading
Load feature modules on demand:
```typescript
// app.routes.ts
export const routes: Routes = [
{ path: "", redirectTo: "/home", pathMatch: "full" },
{ path: "home", component: Home },
// Lazy load entire feature
{
path: "admin",
loadChildren: () =>
import("./admin/admin.routes").then((m) => m.adminRoutes),
},
// Lazy load single component
{
path: "settings",
loadComponent: () =>
import("./settings/settings.component").then((m) => m.Settings),
},
];
// admin/admin.routes.ts
export const adminRoutes: Routes = [
{ path: "", component: AdminDashboard },
{ path: "users", component: AdminUsers },
{ path: "settings", component: AdminSettings },
];
```
## Route Parameters
### With Signal Inputs (Recommended)
```typescript
// Route config
{ path: 'users/:id', component: UserDetail }
// Component - use input() for route params
import { Component, input, computed } from '@angular/core';
@Component({
selector: 'app-user-detail',
template: `
<h1>User {{ id() }}</h1>
`,
})
export class UserDetail {
// Route param as signal input
id = input.required<string>();
// Computed based on route param
userId = computed(() => parseInt(this.id(), 10));
}
```
Enable with `withComponentInputBinding()`:
```typescript
// app.config.ts
import { provideRouter, withComponentInputBinding } from "@angular/router";
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes, withComponentInputBinding())],
};
```
### Query Parameters
```typescript
// Route: /search?q=angular&page=1
@Component({...})
export class Search {
// Query params as inputs
q = input<string>('');
page = input<string>('1');
currentPage = computed(() => parseInt(this.page(), 10));
}
```
### With ActivatedRoute (Alternative)
```typescript
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
@Component({...})
export class UserDetail {
private route = inject(ActivatedRoute);
// Convert route params to signal
id = toSignal(
this.route.paramMap.pipe(map(params => params.get('id'))),
{ initialValue: null }
);
// Query params
query = toSignal(
this.route.queryParamMap.pipe(map(params => params.get('q'))),
{ initialValue: '' }
);
}
```
## Functional Guards
### Auth Guard
```typescript
// guards/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(Auth);
const router = inject(Router);
if (authService.isAuthenticated()) {
return true;
}
// Redirect to login with return URL
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
// Usage in routes
{
path: 'dashboard',
component: Dashboard,
canActivate: [authGuard],
}
```
### Role Guard
```typescript
export const roleGuard = (allowedRoles: string[]): CanActivateFn => {
return (route, state) => {
const authService = inject(Auth);
const router = inject(Router);
const userRole = authService.currentUser()?.role;
if (userRole && allowedRoles.includes(userRole)) {
return true;
}
return router.createUrlTree(['/unauthorized']);
};
};
// Usage
{
path: 'admin',
component: Admin,
canActivate: [authGuard, roleGuard(['admin', 'superadmin'])],
}
```
### Can Deactivate Guard
```typescript
export interface CanDeactivate {
canDeactivate: () => boolean | Promise<boolean>;
}
export const unsavedChangesGuard: CanDeactivateFn<CanDeactivate> = (component) => {
if (component.canDeactivate()) {
return true;
}
return confirm('You have unsaved changes. Leave anyway?');
};
// Component implementation
@Component({...})
export class Edit implements CanDeactivate {
form = inject(FormBuilder).group({...});
canDeactivate(): boolean {
return !this.form.dirty;
}
}
// Route
{
path: 'edit/:id',
component: Edit,
canDeactivate: [unsavedChangesGuard],
}
```
## Resolvers
Pre-fetch data before route activation:
```typescript
// resolvers/user.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
export const userResolver: ResolveFn<User> = (route) => {
const userService = inject(User);
const id = route.paramMap.get('id')!;
return userService.getById(id);
};
// Route config
{
path: 'users/:id',
component: UserDetail,
resolve: { user: userResolver },
}
// Component - access resolved data via input
@Component({...})
export class UserDetail {
user = input.required<User>();
}
```
## Nested Routes
```typescript
// Parent route with children
export const routes: Routes = [
{
path: "products",
component: ProductsLayout,
children: [
{ path: "", component: ProductList },
{ path: ":id", component: ProductDetail },
{ path: ":id/edit", component: ProductEdit },
],
},
];
// ProductsLayout
@Component({
imports: [RouterOutlet],
template: `
<h1>Products</h1>
<router-outlet />
<!-- Child routes render here -->
`,
})
export class ProductsLayout {}
```
## Programmatic Navigation
```typescript
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
@Component({...})
export class Product {
private router = inject(Router);
// Navigate to route
goToProducts() {
this.router.navigate(['/products']);
}
// Navigate with params
goToProduct(id: string) {
this.router.navigate(['/products', id]);
}
// Navigate with query params
search(query: string) {
this.router.navigate(['/search'], {
queryParams: { q: query, page: 1 },
});
}
// Navigate relative to current route
goToEdit() {
this.router.navigate(['edit'], { relativeTo: this.route });
}
// Replace current history entry
replaceUrl() {
this.router.navigate(['/new-page'], { replaceUrl: true });
}
}
```
## Route Data
```typescript
// Static route data
{
path: 'admin',
component: Admin,
data: {
title: 'Admin Dashboard',
roles: ['admin'],
},
}
// Access in component
@Component({...})
export class AdminCmpt {
title = input<string>(); // From route data
roles = input<string[]>(); // From route data
}
// Or via ActivatedRoute
private route = inject(ActivatedRoute);
data = toSignal(this.route.data);
```
## Router Events
```typescript
import { Router, NavigationStart, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs';
@Component({...})
export class AppMain {
private router = inject(Router);
isNavigating = signal(false);
constructor() {
this.router.events.pipe(
filter(e => e instanceof NavigationStart || e instanceof NavigationEnd)
).subscribe(event => {
this.isNavigating.set(event instanceof NavigationStart);
});
}
}
```
For advanced patterns, see [references/routing-patterns.md](references/routing-patterns.md).
@@ -0,0 +1,469 @@
# Angular Routing Patterns
## Table of Contents
- [Route Configuration Options](#route-configuration-options)
- [Authentication Flow](#authentication-flow)
- [Breadcrumbs](#breadcrumbs)
- [Tab Navigation](#tab-navigation)
- [Modal Routes](#modal-routes)
- [Preloading Strategies](#preloading-strategies)
## Route Configuration Options
### Full Route Options
```typescript
{
path: 'users/:id',
component: UserCmpt,
// Lazy loading alternatives
loadComponent: () => import('./user.component').then(m => m.UserCmpt),
loadChildren: () => import('./user.routes').then(m => m.userRoutes),
// Guards
canActivate: [authGuard],
canActivateChild: [authGuard],
canDeactivate: [unsavedChangesGuard],
canMatch: [featureFlagGuard],
// Data
resolve: { user: userResolver },
data: { title: 'User Profile', animation: 'userPage' },
// Children
children: [...],
// Outlet
outlet: 'sidebar',
// Path matching
pathMatch: 'full', // or 'prefix'
// Title
title: 'User Profile',
// Or dynamic title
title: userTitleResolver,
}
```
### Dynamic Title Resolver
```typescript
export const userTitleResolver: ResolveFn<string> = (route) => {
const userService = inject(User);
const id = route.paramMap.get("id")!;
return userService.getById(id).pipe(map((user) => `${user.name} - Profile`));
};
```
## Authentication Flow
### Complete Auth Setup
```typescript
// auth.service.ts
@Injectable({ providedIn: "root" })
export class Auth {
private _user = signal<User | null>(null);
private _token = signal<string | null>(null);
readonly user = this._user.asReadonly();
readonly isAuthenticated = computed(() => this._user() !== null);
private router = inject(Router);
private http = inject(HttpClient);
async login(credentials: Credentials): Promise<boolean> {
try {
const response = await firstValueFrom(
this.http.post<AuthResponse>("/api/login", credentials),
);
this._token.set(response.token);
this._user.set(response.user);
localStorage.setItem("token", response.token);
return true;
} catch {
return false;
}
}
logout(): void {
this._user.set(null);
this._token.set(null);
localStorage.removeItem("token");
this.router.navigate(["/login"]);
}
async checkAuth(): Promise<boolean> {
const token = localStorage.getItem("token");
if (!token) return false;
try {
const user = await firstValueFrom(this.http.get<User>("/api/me"));
this._user.set(user);
this._token.set(token);
return true;
} catch {
localStorage.removeItem("token");
return false;
}
}
}
// auth.guard.ts
export const authGuard: CanActivateFn = async (route, state) => {
const authService = inject(Auth);
const router = inject(Router);
// Check if already authenticated
if (authService.isAuthenticated()) {
return true;
}
// Try to restore session
const isValid = await authService.checkAuth();
if (isValid) {
return true;
}
// Redirect to login
return router.createUrlTree(["/login"], {
queryParams: { returnUrl: state.url },
});
};
// login.component.ts
@Component({
template: `
<form (ngSubmit)="login()">
<input [(ngModel)]="email" name="email" />
<input [(ngModel)]="password" name="password" type="password" />
<button type="submit">Login</button>
</form>
`,
})
export class Login {
private authService = inject(Auth);
private router = inject(Router);
private route = inject(ActivatedRoute);
email = "";
password = "";
async login() {
const success = await this.authService.login({
email: this.email,
password: this.password,
});
if (success) {
const returnUrl = this.route.snapshot.queryParams["returnUrl"] || "/";
this.router.navigateByUrl(returnUrl);
}
}
}
```
## Breadcrumbs
```typescript
// breadcrumb.service.ts
@Injectable({ providedIn: "root" })
export class Breadcrumb {
private router = inject(Router);
private route = inject(ActivatedRoute);
breadcrumbs = toSignal(
this.router.events.pipe(
filter((event) => event instanceof NavigationEnd),
map(() => this.buildBreadcrumbs(this.route.root)),
),
{ initialValue: [] },
);
private buildBreadcrumbs(
route: ActivatedRoute,
url: string = "",
breadcrumbs: Breadcrumb[] = [],
): Breadcrumb[] {
const children = route.children;
if (children.length === 0) {
return breadcrumbs;
}
for (const child of children) {
const routeUrl = child.snapshot.url
.map((segment) => segment.path)
.join("/");
if (routeUrl) {
url += `/${routeUrl}`;
}
const label = child.snapshot.data["breadcrumb"];
if (label) {
breadcrumbs.push({ label, url });
}
return this.buildBreadcrumbs(child, url, breadcrumbs);
}
return breadcrumbs;
}
}
// Route config with breadcrumb data
export const routes: Routes = [
{
path: "products",
data: { breadcrumb: "Products" },
children: [
{ path: "", component: ProductList },
{
path: ":id",
data: { breadcrumb: "Product Details" },
component: ProductDetail,
},
],
},
];
// breadcrumb.component.ts
@Component({
selector: "app-breadcrumb",
template: `
<nav aria-label="Breadcrumb">
<ol>
<li><a routerLink="/">Home</a></li>
@for (crumb of breadcrumbService.breadcrumbs(); track crumb.url) {
<li>
<a [routerLink]="crumb.url">{{ crumb.label }}</a>
</li>
}
</ol>
</nav>
`,
})
export class BreadcrumbCmpt {
breadcrumbService = inject(Breadcrumb);
}
```
## Tab Navigation
```typescript
// tabs-layout.component.ts
@Component({
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<div class="tabs">
@for (tab of tabs; track tab.path) {
<a
[routerLink]="tab.path"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: tab.exact }"
>
{{ tab.label }}
</a>
}
</div>
<div class="tab-content">
<router-outlet />
</div>
`,
})
export class TabsLayout {
tabs = [
{ path: './', label: 'Overview', exact: true },
{ path: 'details', label: 'Details', exact: false },
{ path: 'settings', label: 'Settings', exact: false },
];
}
// Routes
{
path: 'account',
component: TabsLayout,
children: [
{ path: '', component: AccountOverview },
{ path: 'details', component: AccountDetails },
{ path: 'settings', component: AccountSettings },
],
}
```
## Modal Routes
Using auxiliary outlets for modals:
```typescript
// Routes
export const routes: Routes = [
{ path: 'products', component: ProductList },
{ path: 'product-modal/:id', component: ProductModal, outlet: 'modal' },
];
// App template
@Component({
template: `
<router-outlet />
<router-outlet name="modal" />
`,
})
export class App {}
// Open modal
this.router.navigate([{ outlets: { modal: ['product-modal', productId] } }]);
// Close modal
this.router.navigate([{ outlets: { modal: null } }]);
// Link to open modal
<a [routerLink]="[{ outlets: { modal: ['product-modal', product.id] } }]">
View Details
</a>
```
## Preloading Strategies
### Built-in Strategies
```typescript
import {
provideRouter,
withPreloading,
PreloadAllModules,
NoPreloading,
} from "@angular/router";
// Preload all lazy modules
provideRouter(routes, withPreloading(PreloadAllModules));
// No preloading (default)
provideRouter(routes, withPreloading(NoPreloading));
```
### Custom Preloading Strategy
```typescript
// selective-preload.strategy.ts
@Injectable({ providedIn: 'root' })
export class SelectivePreloadStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
// Only preload routes marked with data.preload = true
if (route.data?.['preload']) {
return load();
}
return of(null);
}
}
// Routes
{
path: 'dashboard',
loadComponent: () => import('./dashboard.component'),
data: { preload: true }, // Will be preloaded
}
// Config
provideRouter(routes, withPreloading(SelectivePreloadStrategy))
```
### Network-Aware Preloading
```typescript
@Injectable({ providedIn: "root" })
export class NetworkAwarePreloadStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
// Check network conditions
const connection = (navigator as any).connection;
if (connection) {
// Don't preload on slow connections
if (connection.saveData || connection.effectiveType === "2g") {
return of(null);
}
}
// Preload if marked
if (route.data?.["preload"]) {
return load();
}
return of(null);
}
}
```
## Route Animations
```typescript
// app.routes.ts
export const routes: Routes = [
{ path: "home", component: Home, data: { animation: "HomePage" } },
{ path: "about", component: About, data: { animation: "AboutPage" } },
];
// app.component.ts
@Component({
imports: [RouterOutlet],
template: `
<div [@routeAnimations]="getRouteAnimationData()">
<router-outlet />
</div>
`,
animations: [
trigger("routeAnimations", [
transition("HomePage <=> AboutPage", [
style({ position: "relative" }),
query(":enter, :leave", [
style({
position: "absolute",
top: 0,
left: 0,
width: "100%",
}),
]),
query(":enter", [style({ left: "-100%" })]),
query(":leave", animateChild()),
group([
query(":leave", [animate("300ms ease-out", style({ left: "100%" }))]),
query(":enter", [animate("300ms ease-out", style({ left: "0%" }))]),
]),
]),
]),
],
})
export class AppMain {
getRouteAnimationData() {
return this.route.firstChild?.snapshot.data["animation"];
}
}
```
## Scroll Position Restoration
```typescript
// app.config.ts
import {
provideRouter,
withInMemoryScrolling,
withRouterConfig,
} from "@angular/router";
provideRouter(
routes,
withInMemoryScrolling({
scrollPositionRestoration: "enabled", // or 'top'
anchorScrolling: "enabled",
}),
withRouterConfig({
onSameUrlNavigation: "reload",
}),
);
```