587 lines
12 KiB
Markdown
587 lines
12 KiB
Markdown
---
|
|
name: modern-angular-implementation
|
|
description: Implement Angular 18+ features: Signals, standalone components, @defer blocks, SSR, zoneless change detection, new control flow syntax, and Material 3 integration.
|
|
---
|
|
|
|
# Modern Angular Implementation Skill
|
|
|
|
## Angular Signals
|
|
|
|
### Basic Signals
|
|
```typescript
|
|
import { Component, signal, computed, effect } from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'app-counter',
|
|
standalone: true,
|
|
template: `
|
|
<button (click)="increment()">{{ count() }}</button>
|
|
<p>Double: {{ double() }}</p>
|
|
`
|
|
})
|
|
export class CounterComponent {
|
|
// Writable signal
|
|
count = signal(0);
|
|
|
|
// Computed signal (auto-updates)
|
|
double = computed(() => this.count() * 2);
|
|
|
|
constructor() {
|
|
// Effect (side effects)
|
|
effect(() => {
|
|
console.log('Count changed:', this.count());
|
|
});
|
|
}
|
|
|
|
increment() {
|
|
this.count.update(n => n + 1);
|
|
// or: this.count.set(this.count() + 1);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Signal Store Pattern
|
|
```typescript
|
|
@Injectable({ providedIn: 'root' })
|
|
export class UserStore {
|
|
// Private state signal
|
|
private state = signal<{
|
|
users: User[];
|
|
loading: boolean;
|
|
error: string | null;
|
|
}>({
|
|
users: [],
|
|
loading: false,
|
|
error: null
|
|
});
|
|
|
|
// Public computed selectors
|
|
readonly users = computed(() => this.state().users);
|
|
readonly loading = computed(() => this.state().loading);
|
|
readonly error = computed(() => this.state().error);
|
|
readonly userCount = computed(() => this.users().length);
|
|
|
|
// Actions
|
|
async loadUsers() {
|
|
this.state.update(s => ({ ...s, loading: true }));
|
|
try {
|
|
const users = await this.http.get<User[]>('/api/users');
|
|
this.state.update(s => ({ ...s, users, loading: false }));
|
|
} catch (error) {
|
|
this.state.update(s => ({
|
|
...s,
|
|
error: error.message,
|
|
loading: false
|
|
}));
|
|
}
|
|
}
|
|
|
|
addUser(user: User) {
|
|
this.state.update(s => ({
|
|
...s,
|
|
users: [...s.users, user]
|
|
}));
|
|
}
|
|
}
|
|
```
|
|
|
|
### Signals vs RxJS
|
|
```typescript
|
|
// ❌ OLD: RxJS BehaviorSubject
|
|
private userSubject = new BehaviorSubject<User | null>(null);
|
|
user$ = this.userSubject.asObservable();
|
|
userName$ = this.user$.pipe(map(u => u?.name ?? 'Guest'));
|
|
|
|
ngOnDestroy() {
|
|
this.userSubject.complete();
|
|
}
|
|
|
|
// ✅ NEW: Angular Signals
|
|
user = signal<User | null>(null);
|
|
userName = computed(() => this.user()?.name ?? 'Guest');
|
|
// No cleanup needed!
|
|
```
|
|
|
|
## Standalone Components
|
|
|
|
### Basic Standalone Component
|
|
```typescript
|
|
import { Component } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { RouterModule } from '@angular/router';
|
|
|
|
@Component({
|
|
selector: 'app-dashboard',
|
|
standalone: true,
|
|
imports: [CommonModule, RouterModule], // Import dependencies directly
|
|
template: `
|
|
<h1>Dashboard</h1>
|
|
<router-outlet />
|
|
`
|
|
})
|
|
export class DashboardComponent {}
|
|
```
|
|
|
|
### Standalone Bootstrap
|
|
```typescript
|
|
// main.ts
|
|
import { bootstrapApplication } from '@angular/platform-browser';
|
|
import { provideRouter } from '@angular/router';
|
|
import { provideHttpClient } from '@angular/common/http';
|
|
import { AppComponent } from './app/app.component';
|
|
import { routes } from './app/app.routes';
|
|
|
|
bootstrapApplication(AppComponent, {
|
|
providers: [
|
|
provideRouter(routes),
|
|
provideHttpClient(),
|
|
// Add other providers
|
|
]
|
|
});
|
|
```
|
|
|
|
### Standalone Routes
|
|
```typescript
|
|
// app.routes.ts
|
|
import { Routes } from '@angular/router';
|
|
|
|
export const routes: Routes = [
|
|
{
|
|
path: '',
|
|
loadComponent: () => import('./home/home.component').then(m => m.HomeComponent)
|
|
},
|
|
{
|
|
path: 'users',
|
|
loadChildren: () => import('./users/users.routes').then(m => m.USERS_ROUTES)
|
|
}
|
|
];
|
|
```
|
|
|
|
### Migration Command
|
|
```bash
|
|
# Automated migration to standalone
|
|
ng generate @angular/core:standalone
|
|
```
|
|
|
|
## Deferrable Views (@defer)
|
|
|
|
### Basic @defer
|
|
```typescript
|
|
@defer {
|
|
<app-heavy-component />
|
|
} @placeholder {
|
|
<div class="loading-skeleton"></div>
|
|
}
|
|
```
|
|
|
|
### Defer with Triggers
|
|
```typescript
|
|
// On viewport (when visible)
|
|
@defer (on viewport) {
|
|
<app-chart [data]="data" />
|
|
} @placeholder {
|
|
<div class="chart-placeholder"></div>
|
|
}
|
|
|
|
// On interaction (click or keydown)
|
|
@defer (on interaction) {
|
|
<app-advanced-editor />
|
|
} @placeholder {
|
|
<button>Load Editor</button>
|
|
}
|
|
|
|
// On hover
|
|
@defer (on hover) {
|
|
<app-tooltip [content]="tooltipContent" />
|
|
}
|
|
|
|
// On idle (browser idle)
|
|
@defer (on idle) {
|
|
<app-analytics-dashboard />
|
|
}
|
|
|
|
// On timer
|
|
@defer (on timer(5s)) {
|
|
<app-promotional-banner />
|
|
}
|
|
```
|
|
|
|
### Advanced @defer with States
|
|
```typescript
|
|
@defer (on interaction; prefetch on idle) {
|
|
<app-video-player [src]="videoUrl" />
|
|
} @loading (minimum 500ms; after 100ms) {
|
|
<app-spinner />
|
|
} @placeholder (minimum 1s) {
|
|
<button>Load Video Player</button>
|
|
} @error {
|
|
<p>Failed to load video player</p>
|
|
}
|
|
```
|
|
|
|
### Strategic Deferment
|
|
```typescript
|
|
<div class="page">
|
|
<!-- Critical content loads immediately -->
|
|
<app-header />
|
|
<app-hero-section />
|
|
|
|
<!-- Defer below-the-fold content -->
|
|
@defer (on viewport) {
|
|
<app-features-section />
|
|
}
|
|
|
|
@defer (on viewport) {
|
|
<app-testimonials />
|
|
}
|
|
|
|
<!-- Defer interactive widgets -->
|
|
@defer (on interaction; prefetch on idle) {
|
|
<app-chat-widget />
|
|
} @placeholder {
|
|
<button class="chat-trigger">Chat with us</button>
|
|
}
|
|
</div>
|
|
```
|
|
|
|
## New Control Flow
|
|
|
|
### @if (replaces *ngIf)
|
|
```typescript
|
|
// OLD
|
|
<div *ngIf="user">{{ user.name }}</div>
|
|
<div *ngIf="user; else loading">{{ user.name }}</div>
|
|
|
|
// NEW
|
|
@if (user) {
|
|
<div>{{ user.name }}</div>
|
|
}
|
|
|
|
@if (user) {
|
|
<div>{{ user.name }}</div>
|
|
} @else {
|
|
<div>Loading...</div>
|
|
}
|
|
```
|
|
|
|
### @for (replaces *ngFor)
|
|
```typescript
|
|
// OLD
|
|
<div *ngFor="let item of items; trackBy: trackById">
|
|
{{ item.name }}
|
|
</div>
|
|
|
|
// NEW
|
|
@for (item of items; track item.id) {
|
|
<div>{{ item.name }}</div>
|
|
} @empty {
|
|
<p>No items found</p>
|
|
}
|
|
```
|
|
|
|
### @switch (replaces *ngSwitch)
|
|
```typescript
|
|
// OLD
|
|
<div [ngSwitch]="status">
|
|
<p *ngSwitchCase="'loading'">Loading...</p>
|
|
<p *ngSwitchCase="'error'">Error occurred</p>
|
|
<p *ngSwitchDefault>Success</p>
|
|
</div>
|
|
|
|
// NEW
|
|
@switch (status) {
|
|
@case ('loading') {
|
|
<p>Loading...</p>
|
|
}
|
|
@case ('error') {
|
|
<p>Error occurred</p>
|
|
}
|
|
@default {
|
|
<p>Success</p>
|
|
}
|
|
}
|
|
```
|
|
|
|
### Combined Control Flow
|
|
```typescript
|
|
@if (users.length > 0) {
|
|
<ul>
|
|
@for (user of users; track user.id) {
|
|
<li>
|
|
{{ user.name }}
|
|
@if (user.isAdmin) {
|
|
<span class="badge">Admin</span>
|
|
}
|
|
</li>
|
|
} @empty {
|
|
<li>No users found</li>
|
|
}
|
|
</ul>
|
|
} @else {
|
|
<p>Loading users...</p>
|
|
}
|
|
```
|
|
|
|
## Server-Side Rendering (SSR)
|
|
|
|
### Enable SSR
|
|
```bash
|
|
# Add SSR to existing project
|
|
ng add @angular/ssr
|
|
|
|
# Or create new project with SSR
|
|
ng new my-app --ssr
|
|
```
|
|
|
|
### SSR Configuration
|
|
```typescript
|
|
// app.config.ts
|
|
import { ApplicationConfig } from '@angular/core';
|
|
import { provideClientHydration } from '@angular/platform-browser';
|
|
|
|
export const appConfig: ApplicationConfig = {
|
|
providers: [
|
|
provideClientHydration() // Enable hydration
|
|
]
|
|
};
|
|
```
|
|
|
|
### SSR-Safe Code
|
|
```typescript
|
|
import { Component, Inject, PLATFORM_ID } from '@angular/core';
|
|
import { isPlatformBrowser } from '@angular/common';
|
|
|
|
@Component({...})
|
|
export class MapComponent {
|
|
constructor(@Inject(PLATFORM_ID) private platformId: Object) {}
|
|
|
|
ngOnInit() {
|
|
// Only run in browser
|
|
if (isPlatformBrowser(this.platformId)) {
|
|
this.initializeMap();
|
|
this.loadGoogleMapsAPI();
|
|
}
|
|
}
|
|
|
|
private initializeMap() {
|
|
// Browser-specific code
|
|
const map = new google.maps.Map(document.getElementById('map'));
|
|
}
|
|
}
|
|
```
|
|
|
|
### Transfer State (Avoid Duplicate Requests)
|
|
```typescript
|
|
import { Component, makeStateKey, TransferState } from '@angular/core';
|
|
|
|
const USERS_KEY = makeStateKey<User[]>('users');
|
|
|
|
@Component({...})
|
|
export class UsersComponent {
|
|
constructor(
|
|
private http: HttpClient,
|
|
private transferState: TransferState
|
|
) {}
|
|
|
|
loadUsers() {
|
|
// Check if data exists in transfer state (from SSR)
|
|
const users = this.transferState.get(USERS_KEY, null);
|
|
|
|
if (users) {
|
|
// Use cached data from SSR
|
|
return of(users);
|
|
}
|
|
|
|
// Fetch from API and cache for hydration
|
|
return this.http.get<User[]>('/api/users').pipe(
|
|
tap(users => this.transferState.set(USERS_KEY, users))
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Zoneless Change Detection
|
|
|
|
### Enable Zoneless
|
|
```typescript
|
|
// app.config.ts
|
|
import { ApplicationConfig } from '@angular/core';
|
|
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
|
|
|
|
export const appConfig: ApplicationConfig = {
|
|
providers: [
|
|
provideExperimentalZonelessChangeDetection()
|
|
]
|
|
};
|
|
```
|
|
|
|
### Zoneless-Compatible Code
|
|
```typescript
|
|
@Component({...})
|
|
export class MyComponent {
|
|
count = signal(0); // Signals work great with zoneless!
|
|
|
|
// Manual change detection when needed
|
|
constructor(private cdr: ChangeDetectorRef) {}
|
|
|
|
onManualUpdate() {
|
|
this.legacyProperty = 'new value';
|
|
this.cdr.markForCheck(); // Trigger change detection manually
|
|
}
|
|
}
|
|
```
|
|
|
|
## Material 3
|
|
|
|
### Install Material 3
|
|
```bash
|
|
ng add @angular/material
|
|
```
|
|
|
|
### Material 3 Theme
|
|
```scss
|
|
// styles.scss
|
|
@use '@angular/material' as mat;
|
|
|
|
$my-theme: mat.define-theme((
|
|
color: (
|
|
theme-type: light,
|
|
primary: mat.$azure-palette,
|
|
),
|
|
));
|
|
|
|
html {
|
|
@include mat.all-component-themes($my-theme);
|
|
}
|
|
|
|
// Dark mode
|
|
html.dark-theme {
|
|
$dark-theme: mat.define-theme((
|
|
color: (
|
|
theme-type: dark,
|
|
primary: mat.$azure-palette,
|
|
),
|
|
));
|
|
|
|
@include mat.all-component-colors($dark-theme);
|
|
}
|
|
```
|
|
|
|
### Material 3 Components
|
|
```typescript
|
|
import { Component } from '@angular/core';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatCardModule } from '@angular/material/card';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
|
|
@Component({
|
|
standalone: true,
|
|
imports: [MatButtonModule, MatCardModule, MatIconModule],
|
|
template: `
|
|
<mat-card appearance="outlined">
|
|
<mat-card-header>
|
|
<mat-card-title>Material 3 Card</mat-card-title>
|
|
</mat-card-header>
|
|
<mat-card-content>
|
|
<p>Beautiful Material Design 3 components</p>
|
|
</mat-card-content>
|
|
<mat-card-actions>
|
|
<button mat-button>Action</button>
|
|
<button mat-raised-button color="primary">
|
|
<mat-icon>favorite</mat-icon>
|
|
Primary
|
|
</button>
|
|
</mat-card-actions>
|
|
</mat-card>
|
|
`
|
|
})
|
|
export class MaterialCardComponent {}
|
|
```
|
|
|
|
## Migration Patterns
|
|
|
|
### NgModule → Standalone
|
|
```typescript
|
|
// BEFORE: NgModule
|
|
@NgModule({
|
|
declarations: [UserComponent, UserListComponent],
|
|
imports: [CommonModule, RouterModule],
|
|
exports: [UserComponent]
|
|
})
|
|
export class UserModule {}
|
|
|
|
// AFTER: Standalone
|
|
export const USER_ROUTES: Routes = [{
|
|
path: '',
|
|
loadComponent: () => import('./user.component').then(m => m.UserComponent)
|
|
}];
|
|
|
|
@Component({
|
|
standalone: true,
|
|
imports: [CommonModule, RouterModule]
|
|
})
|
|
export class UserComponent {}
|
|
```
|
|
|
|
### RxJS → Signals
|
|
```typescript
|
|
// BEFORE: RxJS
|
|
class UserService {
|
|
private usersSubject = new BehaviorSubject<User[]>([]);
|
|
users$ = this.usersSubject.asObservable();
|
|
|
|
addUser(user: User) {
|
|
const current = this.usersSubject.value;
|
|
this.usersSubject.next([...current, user]);
|
|
}
|
|
}
|
|
|
|
// AFTER: Signals
|
|
class UserService {
|
|
users = signal<User[]>([]);
|
|
|
|
addUser(user: User) {
|
|
this.users.update(users => [...users, user]);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Performance Optimization
|
|
|
|
### Bundle Size Reduction with @defer
|
|
```typescript
|
|
// Can reduce initial bundle by 40-60%!
|
|
@defer (on viewport) {
|
|
<app-heavy-chart-library />
|
|
}
|
|
```
|
|
|
|
### Zoneless Performance Gains
|
|
```typescript
|
|
// 20-30% performance improvement
|
|
provideExperimentalZonelessChangeDetection()
|
|
```
|
|
|
|
### SSR Core Web Vitals
|
|
```typescript
|
|
// Dramatically improves LCP, FCP, TTFB
|
|
provideClientHydration()
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
1. **Use Signals for Simple State** - Perfect for component-local reactive state
|
|
2. **Keep RxJS for Complex Async** - Still best for HTTP, WebSockets, complex operators
|
|
3. **Strategic @defer** - Don't defer critical content, be strategic
|
|
4. **Gradual Migration** - Migrate to standalone incrementally
|
|
5. **SSR-Safe Guards** - Always check isPlatformBrowser for DOM access
|
|
6. **Zoneless-Ready** - Use Signals and OnPush to prepare for zoneless future
|
|
|
|
## Resources
|
|
|
|
- [Signals Documentation](https://angular.dev/guide/signals)
|
|
- [Standalone Migration](https://angular.dev/reference/migrations/standalone)
|
|
- [@defer Guide](https://angular.dev/guide/templates/defer)
|
|
- [SSR Guide](https://angular.dev/guide/ssr)
|
|
- [Material 3](https://material.angular.io)
|