Files
gh-pluginagentmarketplace-c…/skills/modern-angular/SKILL.md
2025-11-30 08:47:33 +08:00

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

  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