12 KiB
12 KiB
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
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
@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
// ❌ 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
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
// 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
// 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
# Automated migration to standalone
ng generate @angular/core:standalone
Deferrable Views (@defer)
Basic @defer
@defer {
<app-heavy-component />
} @placeholder {
<div class="loading-skeleton"></div>
}
Defer with Triggers
// 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
@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
<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)
// 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)
// 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)
// 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
@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
# Add SSR to existing project
ng add @angular/ssr
# Or create new project with SSR
ng new my-app --ssr
SSR Configuration
// 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
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)
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
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
provideExperimentalZonelessChangeDetection()
]
};
Zoneless-Compatible Code
@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
ng add @angular/material
Material 3 Theme
// 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
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
// 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
// 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
// Can reduce initial bundle by 40-60%!
@defer (on viewport) {
<app-heavy-chart-library />
}
Zoneless Performance Gains
// 20-30% performance improvement
provideExperimentalZonelessChangeDetection()
SSR Core Web Vitals
// Dramatically improves LCP, FCP, TTFB
provideClientHydration()
Best Practices
- Use Signals for Simple State - Perfect for component-local reactive state
- Keep RxJS for Complex Async - Still best for HTTP, WebSockets, complex operators
- Strategic @defer - Don't defer critical content, be strategic
- Gradual Migration - Migrate to standalone incrementally
- SSR-Safe Guards - Always check isPlatformBrowser for DOM access
- Zoneless-Ready - Use Signals and OnPush to prepare for zoneless future