diff --git a/skills/angular-best-practices/SKILL.md b/skills/angular-best-practices/SKILL.md new file mode 100644 index 00000000..9b375384 --- /dev/null +++ b/skills/angular-best-practices/SKILL.md @@ -0,0 +1,502 @@ +--- +name: angular-best-practices +description: Angular performance optimization and best practices guide. Use when writing, reviewing, or refactoring Angular code for optimal performance, bundle size, and rendering efficiency. +--- + +# Angular Best Practices + +Comprehensive performance optimization guide for Angular applications. Contains prioritized rules for eliminating performance bottlenecks, optimizing bundles, and improving rendering. + +## When to Apply + +Reference these guidelines when: + +- Writing new Angular components or pages +- Implementing data fetching patterns +- Reviewing code for performance issues +- Refactoring existing Angular code +- Optimizing bundle size or load times +- Configuring SSR/hydration + +--- + +## Rule Categories by Priority + +| Priority | Category | Impact | Focus | +| -------- | --------------------- | ---------- | ------------------------------- | +| 1 | Change Detection | CRITICAL | Signals, OnPush, Zoneless | +| 2 | Bundle Optimization | CRITICAL | Lazy loading, tree shaking | +| 3 | Rendering Performance | HIGH | @defer, trackBy, virtualization | +| 4 | Server-Side Rendering | HIGH | Hydration, prerendering | +| 5 | Template Optimization | MEDIUM | Control flow, pipes | +| 6 | State Management | MEDIUM | Signal patterns, selectors | +| 7 | Memory Management | LOW-MEDIUM | Cleanup, subscriptions | + +--- + +## 1. Change Detection (CRITICAL) + +### Use OnPush Change Detection + +```typescript +// CORRECT - OnPush with Signals +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + template: `
{{ count() }}
`, +}) +export class CounterComponent { + count = signal(0); +} + +// WRONG - Default change detection +@Component({ + template: `
{{ count }}
`, // Checked every cycle +}) +export class CounterComponent { + count = 0; +} +``` + +### Prefer Signals Over Mutable Properties + +```typescript +// CORRECT - Signals trigger precise updates +@Component({ + template: ` +

{{ title() }}

+

Count: {{ count() }}

+ `, +}) +export class DashboardComponent { + title = signal("Dashboard"); + count = signal(0); +} + +// WRONG - Mutable properties require zone.js checks +@Component({ + template: ` +

{{ title }}

+

Count: {{ count }}

+ `, +}) +export class DashboardComponent { + title = "Dashboard"; + count = 0; +} +``` + +### Enable Zoneless for New Projects + +```typescript +// main.ts - Zoneless Angular (v20+) +bootstrapApplication(AppComponent, { + providers: [provideZonelessChangeDetection()], +}); +``` + +**Benefits:** + +- No zone.js patches on async APIs +- Smaller bundle (~15KB savings) +- Clean stack traces for debugging +- Better micro-frontend compatibility + +--- + +## 2. Bundle Optimization (CRITICAL) + +### Lazy Load Routes + +```typescript +// CORRECT - Lazy load feature routes +export const routes: Routes = [ + { + path: "admin", + loadChildren: () => + import("./admin/admin.routes").then((m) => m.ADMIN_ROUTES), + }, + { + path: "dashboard", + loadComponent: () => + import("./dashboard/dashboard.component").then( + (m) => m.DashboardComponent, + ), + }, +]; + +// WRONG - Eager loading everything +import { AdminModule } from "./admin/admin.module"; +export const routes: Routes = [ + { path: "admin", component: AdminComponent }, // In main bundle +]; +``` + +### Use @defer for Heavy Components + +```html + +@defer (on viewport) { + +} @placeholder { +
+} + + + +``` + +### Avoid Barrel File Re-exports + +```typescript +// WRONG - Imports entire barrel, breaks tree-shaking +import { Button, Modal, Table } from "@shared/components"; + +// CORRECT - Direct imports +import { Button } from "@shared/components/button/button.component"; +import { Modal } from "@shared/components/modal/modal.component"; +``` + +### Dynamic Import Third-Party Libraries + +```typescript +// CORRECT - Load heavy library on demand +async loadChart() { + const { Chart } = await import('chart.js'); + this.chart = new Chart(this.canvas, config); +} + +// WRONG - Bundle Chart.js in main chunk +import { Chart } from 'chart.js'; +``` + +--- + +## 3. Rendering Performance (HIGH) + +### Always Use trackBy with @for + +```html + +@for (item of items(); track item.id) { + +} + + +@for (item of items(); track $index) { + +} +``` + +### Use Virtual Scrolling for Large Lists + +```typescript +import { CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll } from '@angular/cdk/scrolling'; + +@Component({ + imports: [CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll], + template: ` + +
+ {{ item.name }} +
+
+ ` +}) +``` + +### Prefer Pure Pipes Over Methods + +```typescript +// CORRECT - Pure pipe, memoized +@Pipe({ name: 'filterActive', standalone: true, pure: true }) +export class FilterActivePipe implements PipeTransform { + transform(items: Item[]): Item[] { + return items.filter(i => i.active); + } +} + +// Template +@for (item of items() | filterActive; track item.id) { ... } + +// WRONG - Method called every change detection +@for (item of getActiveItems(); track item.id) { ... } +``` + +### Use computed() for Derived Data + +```typescript +// CORRECT - Computed, cached until dependencies change +export class ProductStore { + products = signal([]); + filter = signal(''); + + filteredProducts = computed(() => { + const f = this.filter().toLowerCase(); + return this.products().filter(p => + p.name.toLowerCase().includes(f) + ); + }); +} + +// WRONG - Recalculates every access +get filteredProducts() { + return this.products.filter(p => + p.name.toLowerCase().includes(this.filter) + ); +} +``` + +--- + +## 4. Server-Side Rendering (HIGH) + +### Configure Incremental Hydration + +```typescript +// app.config.ts +import { + provideClientHydration, + withIncrementalHydration, +} from "@angular/platform-browser"; + +export const appConfig: ApplicationConfig = { + providers: [ + provideClientHydration(withIncrementalHydration(), withEventReplay()), + ], +}; +``` + +### Defer Non-Critical Content + +```html + + + + + +@defer (hydrate on viewport) { + +} @defer (hydrate on interaction) { + +} +``` + +### Use TransferState for SSR Data + +```typescript +@Injectable({ providedIn: "root" }) +export class DataService { + private http = inject(HttpClient); + private transferState = inject(TransferState); + private platformId = inject(PLATFORM_ID); + + getData(key: string): Observable { + const stateKey = makeStateKey(key); + + if (isPlatformBrowser(this.platformId)) { + const cached = this.transferState.get(stateKey, null); + if (cached) { + this.transferState.remove(stateKey); + return of(cached); + } + } + + return this.http.get(`/api/${key}`).pipe( + tap((data) => { + if (isPlatformServer(this.platformId)) { + this.transferState.set(stateKey, data); + } + }), + ); + } +} +``` + +--- + +## 5. Template Optimization (MEDIUM) + +### Use New Control Flow Syntax + +```html + +@if (user()) { +{{ user()!.name }} +} @else { +Guest +} @for (item of items(); track item.id) { + +} @empty { +

No items

+} + + +{{ user.name }} +Guest +``` + +### Avoid Complex Template Expressions + +```typescript +// CORRECT - Precompute in component +class Component { + items = signal([]); + sortedItems = computed(() => + [...this.items()].sort((a, b) => a.name.localeCompare(b.name)) + ); +} + +// Template +@for (item of sortedItems(); track item.id) { ... } + +// WRONG - Sorting in template every render +@for (item of items() | sort:'name'; track item.id) { ... } +``` + +--- + +## 6. State Management (MEDIUM) + +### Use Selectors to Prevent Re-renders + +```typescript +// CORRECT - Selective subscription +@Component({ + template: `{{ userName() }}`, +}) +class HeaderComponent { + private store = inject(Store); + // Only re-renders when userName changes + userName = this.store.selectSignal(selectUserName); +} + +// WRONG - Subscribing to entire state +@Component({ + template: `{{ state().user.name }}`, +}) +class HeaderComponent { + private store = inject(Store); + // Re-renders on ANY state change + state = toSignal(this.store); +} +``` + +### Colocate State with Features + +```typescript +// CORRECT - Feature-scoped store +@Injectable() // NOT providedIn: 'root' +export class ProductStore { ... } + +@Component({ + providers: [ProductStore], // Scoped to component tree +}) +export class ProductPageComponent { + store = inject(ProductStore); +} + +// WRONG - Everything in global store +@Injectable({ providedIn: 'root' }) +export class GlobalStore { + // Contains ALL app state - hard to tree-shake +} +``` + +--- + +## 7. Memory Management (LOW-MEDIUM) + +### Use takeUntilDestroyed for Subscriptions + +```typescript +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +@Component({...}) +export class DataComponent { + private destroyRef = inject(DestroyRef); + + constructor() { + this.data$.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(data => this.process(data)); + } +} + +// WRONG - Manual subscription management +export class DataComponent implements OnDestroy { + private subscription!: Subscription; + + ngOnInit() { + this.subscription = this.data$.subscribe(...); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); // Easy to forget + } +} +``` + +### Prefer Signals Over Subscriptions + +```typescript +// CORRECT - No subscription needed +@Component({ + template: `
{{ data().name }}
`, +}) +export class Component { + data = toSignal(this.service.data$, { initialValue: null }); +} + +// WRONG - Manual subscription +@Component({ + template: `
{{ data?.name }}
`, +}) +export class Component implements OnInit, OnDestroy { + data: Data | null = null; + private sub!: Subscription; + + ngOnInit() { + this.sub = this.service.data$.subscribe((d) => (this.data = d)); + } + + ngOnDestroy() { + this.sub.unsubscribe(); + } +} +``` + +--- + +## Quick Reference Checklist + +### New Component + +- [ ] `changeDetection: ChangeDetectionStrategy.OnPush` +- [ ] `standalone: true` +- [ ] Signals for state (`signal()`, `input()`, `output()`) +- [ ] `inject()` for dependencies +- [ ] `@for` with `track` expression + +### Performance Review + +- [ ] No methods in templates (use pipes or computed) +- [ ] Large lists virtualized +- [ ] Heavy components deferred +- [ ] Routes lazy-loaded +- [ ] Third-party libs dynamically imported + +### SSR Check + +- [ ] Hydration configured +- [ ] Critical content renders first +- [ ] Non-critical content uses `@defer (hydrate on ...)` +- [ ] TransferState for server-fetched data + +--- + +## Resources + +- [Angular Performance Guide](https://angular.dev/best-practices/performance) +- [Zoneless Angular](https://angular.dev/guide/experimental/zoneless) +- [Angular SSR Guide](https://angular.dev/guide/ssr) +- [Change Detection Deep Dive](https://angular.dev/guide/change-detection) diff --git a/skills/angular-state-management/SKILL.md b/skills/angular-state-management/SKILL.md new file mode 100644 index 00000000..d8afaa87 --- /dev/null +++ b/skills/angular-state-management/SKILL.md @@ -0,0 +1,632 @@ +--- +name: angular-state-management +description: Master modern Angular state management with Signals, NgRx, and RxJS. Use when setting up global state, managing component stores, choosing between state solutions, or migrating from legacy patterns. +--- + +# Angular State Management + +Comprehensive guide to modern Angular state management patterns, from Signal-based local state to global stores and server state synchronization. + +## When to Use This Skill + +- Setting up global state management in Angular +- Choosing between Signals, NgRx, or Akita +- Managing component-level stores +- Implementing optimistic updates +- Debugging state-related issues +- Migrating from legacy state patterns + +## Do Not Use This Skill When + +- The task is unrelated to Angular state management +- You need React state management → use `react-state-management` + +--- + +## Core Concepts + +### State Categories + +| Type | Description | Solutions | +| ---------------- | ---------------------------- | --------------------- | +| **Local State** | Component-specific, UI state | Signals, `signal()` | +| **Shared State** | Between related components | Signal services | +| **Global State** | App-wide, complex | NgRx, Akita, Elf | +| **Server State** | Remote data, caching | NgRx Query, RxAngular | +| **URL State** | Route parameters | ActivatedRoute | +| **Form State** | Input values, validation | Reactive Forms | + +### Selection Criteria + +``` +Small app, simple state → Signal Services +Medium app, moderate state → Component Stores +Large app, complex state → NgRx Store +Heavy server interaction → NgRx Query + Signal Services +Real-time updates → RxAngular + Signals +``` + +--- + +## Quick Start: Signal-Based State + +### Pattern 1: Simple Signal Service + +```typescript +// services/counter.service.ts +import { Injectable, signal, computed } from "@angular/core"; + +@Injectable({ providedIn: "root" }) +export class CounterService { + // Private writable signals + private _count = signal(0); + + // Public read-only + readonly count = this._count.asReadonly(); + readonly doubled = computed(() => this._count() * 2); + readonly isPositive = computed(() => this._count() > 0); + + increment() { + this._count.update((v) => v + 1); + } + + decrement() { + this._count.update((v) => v - 1); + } + + reset() { + this._count.set(0); + } +} + +// Usage in component +@Component({ + template: ` +

Count: {{ counter.count() }}

+

Doubled: {{ counter.doubled() }}

+ + `, +}) +export class CounterComponent { + counter = inject(CounterService); +} +``` + +### Pattern 2: Feature Signal Store + +```typescript +// stores/user.store.ts +import { Injectable, signal, computed, inject } from "@angular/core"; +import { HttpClient } from "@angular/common/http"; +import { toSignal } from "@angular/core/rxjs-interop"; + +interface User { + id: string; + name: string; + email: string; +} + +interface UserState { + user: User | null; + loading: boolean; + error: string | null; +} + +@Injectable({ providedIn: "root" }) +export class UserStore { + private http = inject(HttpClient); + + // State signals + private _user = signal(null); + private _loading = signal(false); + private _error = signal(null); + + // Selectors (read-only computed) + readonly user = computed(() => this._user()); + readonly loading = computed(() => this._loading()); + readonly error = computed(() => this._error()); + readonly isAuthenticated = computed(() => this._user() !== null); + readonly displayName = computed(() => this._user()?.name ?? "Guest"); + + // Actions + async loadUser(id: string) { + this._loading.set(true); + this._error.set(null); + + try { + const user = await fetch(`/api/users/${id}`).then((r) => r.json()); + this._user.set(user); + } catch (e) { + this._error.set("Failed to load user"); + } finally { + this._loading.set(false); + } + } + + updateUser(updates: Partial) { + this._user.update((user) => (user ? { ...user, ...updates } : null)); + } + + logout() { + this._user.set(null); + this._error.set(null); + } +} +``` + +### Pattern 3: SignalStore (NgRx Signals) + +```typescript +// stores/products.store.ts +import { + signalStore, + withState, + withMethods, + withComputed, + patchState, +} from "@ngrx/signals"; +import { inject } from "@angular/core"; +import { ProductService } from "./product.service"; + +interface ProductState { + products: Product[]; + loading: boolean; + filter: string; +} + +const initialState: ProductState = { + products: [], + loading: false, + filter: "", +}; + +export const ProductStore = signalStore( + { providedIn: "root" }, + + withState(initialState), + + withComputed((store) => ({ + filteredProducts: computed(() => { + const filter = store.filter().toLowerCase(); + return store + .products() + .filter((p) => p.name.toLowerCase().includes(filter)); + }), + totalCount: computed(() => store.products().length), + })), + + withMethods((store, productService = inject(ProductService)) => ({ + async loadProducts() { + patchState(store, { loading: true }); + + try { + const products = await productService.getAll(); + patchState(store, { products, loading: false }); + } catch { + patchState(store, { loading: false }); + } + }, + + setFilter(filter: string) { + patchState(store, { filter }); + }, + + addProduct(product: Product) { + patchState(store, ({ products }) => ({ + products: [...products, product], + })); + }, + })), +); + +// Usage +@Component({ + template: ` + + @if (store.loading()) { + + } @else { + @for (product of store.filteredProducts(); track product.id) { + + } + } + `, +}) +export class ProductListComponent { + store = inject(ProductStore); + + ngOnInit() { + this.store.loadProducts(); + } +} +``` + +--- + +## NgRx Store (Global State) + +### Setup + +```typescript +// store/app.state.ts +import { ActionReducerMap } from "@ngrx/store"; + +export interface AppState { + user: UserState; + cart: CartState; +} + +export const reducers: ActionReducerMap = { + user: userReducer, + cart: cartReducer, +}; + +// main.ts +bootstrapApplication(AppComponent, { + providers: [ + provideStore(reducers), + provideEffects([UserEffects, CartEffects]), + provideStoreDevtools({ maxAge: 25 }), + ], +}); +``` + +### Feature Slice Pattern + +```typescript +// store/user/user.actions.ts +import { createActionGroup, props, emptyProps } from "@ngrx/store"; + +export const UserActions = createActionGroup({ + source: "User", + events: { + "Load User": props<{ userId: string }>(), + "Load User Success": props<{ user: User }>(), + "Load User Failure": props<{ error: string }>(), + "Update User": props<{ updates: Partial }>(), + Logout: emptyProps(), + }, +}); +``` + +```typescript +// store/user/user.reducer.ts +import { createReducer, on } from "@ngrx/store"; +import { UserActions } from "./user.actions"; + +export interface UserState { + user: User | null; + loading: boolean; + error: string | null; +} + +const initialState: UserState = { + user: null, + loading: false, + error: null, +}; + +export const userReducer = createReducer( + initialState, + + on(UserActions.loadUser, (state) => ({ + ...state, + loading: true, + error: null, + })), + + on(UserActions.loadUserSuccess, (state, { user }) => ({ + ...state, + user, + loading: false, + })), + + on(UserActions.loadUserFailure, (state, { error }) => ({ + ...state, + loading: false, + error, + })), + + on(UserActions.logout, () => initialState), +); +``` + +```typescript +// store/user/user.selectors.ts +import { createFeatureSelector, createSelector } from "@ngrx/store"; +import { UserState } from "./user.reducer"; + +export const selectUserState = createFeatureSelector("user"); + +export const selectUser = createSelector( + selectUserState, + (state) => state.user, +); + +export const selectUserLoading = createSelector( + selectUserState, + (state) => state.loading, +); + +export const selectIsAuthenticated = createSelector( + selectUser, + (user) => user !== null, +); +``` + +```typescript +// store/user/user.effects.ts +import { Injectable, inject } from "@angular/core"; +import { Actions, createEffect, ofType } from "@ngrx/effects"; +import { switchMap, map, catchError, of } from "rxjs"; + +@Injectable() +export class UserEffects { + private actions$ = inject(Actions); + private userService = inject(UserService); + + loadUser$ = createEffect(() => + this.actions$.pipe( + ofType(UserActions.loadUser), + switchMap(({ userId }) => + this.userService.getUser(userId).pipe( + map((user) => UserActions.loadUserSuccess({ user })), + catchError((error) => + of(UserActions.loadUserFailure({ error: error.message })), + ), + ), + ), + ), + ); +} +``` + +### Component Usage + +```typescript +@Component({ + template: ` + @if (loading()) { + + } @else if (user(); as user) { +

Welcome, {{ user.name }}

+ + } + `, +}) +export class HeaderComponent { + private store = inject(Store); + + user = this.store.selectSignal(selectUser); + loading = this.store.selectSignal(selectUserLoading); + + logout() { + this.store.dispatch(UserActions.logout()); + } +} +``` + +--- + +## RxJS-Based Patterns + +### Component Store (Local Feature State) + +```typescript +// stores/todo.store.ts +import { Injectable } from "@angular/core"; +import { ComponentStore } from "@ngrx/component-store"; +import { switchMap, tap, catchError, EMPTY } from "rxjs"; + +interface TodoState { + todos: Todo[]; + loading: boolean; +} + +@Injectable() +export class TodoStore extends ComponentStore { + constructor(private todoService: TodoService) { + super({ todos: [], loading: false }); + } + + // Selectors + readonly todos$ = this.select((state) => state.todos); + readonly loading$ = this.select((state) => state.loading); + readonly completedCount$ = this.select( + this.todos$, + (todos) => todos.filter((t) => t.completed).length, + ); + + // Updaters + readonly addTodo = this.updater((state, todo: Todo) => ({ + ...state, + todos: [...state.todos, todo], + })); + + readonly toggleTodo = this.updater((state, id: string) => ({ + ...state, + todos: state.todos.map((t) => + t.id === id ? { ...t, completed: !t.completed } : t, + ), + })); + + // Effects + readonly loadTodos = this.effect((trigger$) => + trigger$.pipe( + tap(() => this.patchState({ loading: true })), + switchMap(() => + this.todoService.getAll().pipe( + tap({ + next: (todos) => this.patchState({ todos, loading: false }), + error: () => this.patchState({ loading: false }), + }), + catchError(() => EMPTY), + ), + ), + ), + ); +} +``` + +--- + +## Server State with Signals + +### HTTP + Signals Pattern + +```typescript +// services/api.service.ts +import { Injectable, signal, inject } from "@angular/core"; +import { HttpClient } from "@angular/common/http"; +import { toSignal } from "@angular/core/rxjs-interop"; + +interface ApiState { + data: T | null; + loading: boolean; + error: string | null; +} + +@Injectable({ providedIn: "root" }) +export class ProductApiService { + private http = inject(HttpClient); + + private _state = signal>({ + data: null, + loading: false, + error: null, + }); + + readonly products = computed(() => this._state().data ?? []); + readonly loading = computed(() => this._state().loading); + readonly error = computed(() => this._state().error); + + async fetchProducts(): Promise { + this._state.update((s) => ({ ...s, loading: true, error: null })); + + try { + const data = await firstValueFrom( + this.http.get("/api/products"), + ); + this._state.update((s) => ({ ...s, data, loading: false })); + } catch (e) { + this._state.update((s) => ({ + ...s, + loading: false, + error: "Failed to fetch products", + })); + } + } + + // Optimistic update + async deleteProduct(id: string): Promise { + const previousData = this._state().data; + + // Optimistically remove + this._state.update((s) => ({ + ...s, + data: s.data?.filter((p) => p.id !== id) ?? null, + })); + + try { + await firstValueFrom(this.http.delete(`/api/products/${id}`)); + } catch { + // Rollback on error + this._state.update((s) => ({ ...s, data: previousData })); + } + } +} +``` + +--- + +## Best Practices + +### Do's + +| Practice | Why | +| ---------------------------------- | ---------------------------------- | +| Use Signals for local state | Simple, reactive, no subscriptions | +| Use `computed()` for derived data | Auto-updates, memoized | +| Colocate state with feature | Easier to maintain | +| Use NgRx for complex flows | Actions, effects, devtools | +| Prefer `inject()` over constructor | Cleaner, works in factories | + +### Don'ts + +| Anti-Pattern | Instead | +| --------------------------------- | ----------------------------------------------------- | +| Store derived data | Use `computed()` | +| Mutate signals directly | Use `set()` or `update()` | +| Over-globalize state | Keep local when possible | +| Mix RxJS and Signals chaotically | Choose primary, bridge with `toSignal`/`toObservable` | +| Subscribe in components for state | Use template with signals | + +--- + +## Migration Path + +### From BehaviorSubject to Signals + +```typescript +// Before: RxJS-based +@Injectable({ providedIn: "root" }) +export class OldUserService { + private userSubject = new BehaviorSubject(null); + user$ = this.userSubject.asObservable(); + + setUser(user: User) { + this.userSubject.next(user); + } +} + +// After: Signal-based +@Injectable({ providedIn: "root" }) +export class UserService { + private _user = signal(null); + readonly user = this._user.asReadonly(); + + setUser(user: User) { + this._user.set(user); + } +} +``` + +### Bridging Signals and RxJS + +```typescript +import { toSignal, toObservable } from '@angular/core/rxjs-interop'; + +// Observable → Signal +@Component({...}) +export class ExampleComponent { + private route = inject(ActivatedRoute); + + // Convert Observable to Signal + userId = toSignal( + this.route.params.pipe(map(p => p['id'])), + { initialValue: '' } + ); +} + +// Signal → Observable +export class DataService { + private filter = signal(''); + + // Convert Signal to Observable + filter$ = toObservable(this.filter); + + filteredData$ = this.filter$.pipe( + debounceTime(300), + switchMap(filter => this.http.get(`/api/data?q=${filter}`)) + ); +} +``` + +--- + +## Resources + +- [Angular Signals Guide](https://angular.dev/guide/signals) +- [NgRx Documentation](https://ngrx.io/) +- [NgRx SignalStore](https://ngrx.io/guide/signals) +- [RxAngular](https://www.rx-angular.io/) diff --git a/skills/angular-ui-patterns/SKILL.md b/skills/angular-ui-patterns/SKILL.md new file mode 100644 index 00000000..cc9601cd --- /dev/null +++ b/skills/angular-ui-patterns/SKILL.md @@ -0,0 +1,506 @@ +--- +name: angular-ui-patterns +description: Modern Angular UI patterns for loading states, error handling, and data display. Use when building UI components, handling async data, or managing component states. +--- + +# Angular UI Patterns + +## Core Principles + +1. **Never show stale UI** - Loading states only when actually loading +2. **Always surface errors** - Users must know when something fails +3. **Optimistic updates** - Make the UI feel instant +4. **Progressive disclosure** - Use `@defer` to show content as available +5. **Graceful degradation** - Partial data is better than no data + +--- + +## Loading State Patterns + +### The Golden Rule + +**Show loading indicator ONLY when there's no data to display.** + +```typescript +@Component({ + template: ` + @if (error()) { + + } @else if (loading() && !items().length) { + + } @else if (!items().length) { + + } @else { + + } + `, +}) +export class ItemListComponent { + private store = inject(ItemStore); + + items = this.store.items; + loading = this.store.loading; + error = this.store.error; +} +``` + +### Loading State Decision Tree + +``` +Is there an error? + → Yes: Show error state with retry option + → No: Continue + +Is it loading AND we have no data? + → Yes: Show loading indicator (spinner/skeleton) + → No: Continue + +Do we have data? + → Yes, with items: Show the data + → Yes, but empty: Show empty state + → No: Show loading (fallback) +``` + +### Skeleton vs Spinner + +| Use Skeleton When | Use Spinner When | +| -------------------- | --------------------- | +| Known content shape | Unknown content shape | +| List/card layouts | Modal actions | +| Initial page load | Button submissions | +| Content placeholders | Inline operations | + +--- + +## Control Flow Patterns + +### @if/@else for Conditional Rendering + +```html +@if (user(); as user) { +Welcome, {{ user.name }} +} @else if (loading()) { + +} @else { +Sign In +} +``` + +### @for with Track + +```html +@for (item of items(); track item.id) { + +} @empty { + +} +``` + +### @defer for Progressive Loading + +```html + + + + + +@defer (on viewport) { + +} @placeholder { +
+} @loading (minimum 200ms) { + +} @error { + +} +``` + +--- + +## Error Handling Patterns + +### Error Handling Hierarchy + +``` +1. Inline error (field-level) → Form validation errors +2. Toast notification → Recoverable errors, user can retry +3. Error banner → Page-level errors, data still partially usable +4. Full error screen → Unrecoverable, needs user action +``` + +### Always Show Errors + +**CRITICAL: Never swallow errors silently.** + +```typescript +// CORRECT - Error always surfaced to user +@Component({...}) +export class CreateItemComponent { + private store = inject(ItemStore); + private toast = inject(ToastService); + + async create(data: CreateItemDto) { + try { + await this.store.create(data); + this.toast.success('Item created successfully'); + this.router.navigate(['/items']); + } catch (error) { + console.error('createItem failed:', error); + this.toast.error('Failed to create item. Please try again.'); + } + } +} + +// WRONG - Error silently caught +async create(data: CreateItemDto) { + try { + await this.store.create(data); + } catch (error) { + console.error(error); // User sees nothing! + } +} +``` + +### Error State Component Pattern + +```typescript +@Component({ + selector: "app-error-state", + standalone: true, + imports: [NgOptimizedImage], + template: ` +
+ +

{{ title() }}

+

{{ message() }}

+ @if (retry.observed) { + + } +
+ `, +}) +export class ErrorStateComponent { + title = input("Something went wrong"); + message = input("An unexpected error occurred"); + retry = output(); +} +``` + +--- + +## Button State Patterns + +### Button Loading State + +```html + +``` + +### Disable During Operations + +**CRITICAL: Always disable triggers during async operations.** + +```typescript +// CORRECT - Button disabled while loading +@Component({ + template: ` + + ` +}) +export class SaveButtonComponent { + saving = signal(false); + + async save() { + this.saving.set(true); + try { + await this.service.save(); + } finally { + this.saving.set(false); + } + } +} + +// WRONG - User can click multiple times + +``` + +--- + +## Empty States + +### Empty State Requirements + +Every list/collection MUST have an empty state: + +```html +@for (item of items(); track item.id) { + +} @empty { + +} +``` + +### Contextual Empty States + +```typescript +@Component({ + selector: "app-empty-state", + template: ` +
+ +

{{ title() }}

+

{{ description() }}

+ @if (actionLabel()) { + + } +
+ `, +}) +export class EmptyStateComponent { + icon = input("inbox"); + title = input.required(); + description = input(""); + actionLabel = input(null); + action = output(); +} +``` + +--- + +## Form Patterns + +### Form with Loading and Validation + +```typescript +@Component({ + template: ` +
+
+ + + @if (isFieldInvalid("name")) { + + {{ getFieldError("name") }} + + } +
+ +
+ + + @if (isFieldInvalid("email")) { + + {{ getFieldError("email") }} + + } +
+ + +
+ `, +}) +export class UserFormComponent { + private fb = inject(FormBuilder); + + submitting = signal(false); + + form = this.fb.group({ + name: ["", [Validators.required, Validators.minLength(2)]], + email: ["", [Validators.required, Validators.email]], + }); + + isFieldInvalid(field: string): boolean { + const control = this.form.get(field); + return control ? control.invalid && control.touched : false; + } + + getFieldError(field: string): string { + const control = this.form.get(field); + if (control?.hasError("required")) return "This field is required"; + if (control?.hasError("email")) return "Invalid email format"; + if (control?.hasError("minlength")) return "Too short"; + return ""; + } + + async onSubmit() { + if (this.form.invalid) return; + + this.submitting.set(true); + try { + await this.service.submit(this.form.value); + this.toast.success("Submitted successfully"); + } catch { + this.toast.error("Submission failed"); + } finally { + this.submitting.set(false); + } + } +} +``` + +--- + +## Dialog/Modal Patterns + +### Confirmation Dialog + +```typescript +// dialog.service.ts +@Injectable({ providedIn: 'root' }) +export class DialogService { + private dialog = inject(Dialog); // CDK Dialog or custom + + async confirm(options: { + title: string; + message: string; + confirmText?: string; + cancelText?: string; + }): Promise { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + data: options, + }); + + return await firstValueFrom(dialogRef.closed) ?? false; + } +} + +// Usage +async deleteItem(item: Item) { + const confirmed = await this.dialog.confirm({ + title: 'Delete Item', + message: `Are you sure you want to delete "${item.name}"?`, + confirmText: 'Delete', + }); + + if (confirmed) { + await this.store.delete(item.id); + } +} +``` + +--- + +## Anti-Patterns + +### Loading States + +```typescript +// WRONG - Spinner when data exists (causes flash on refetch) +@if (loading()) { + +} + +// CORRECT - Only show loading without data +@if (loading() && !items().length) { + +} +``` + +### Error Handling + +```typescript +// WRONG - Error swallowed +try { + await this.service.save(); +} catch (e) { + console.log(e); // User has no idea! +} + +// CORRECT - Error surfaced +try { + await this.service.save(); +} catch (e) { + console.error("Save failed:", e); + this.toast.error("Failed to save. Please try again."); +} +``` + +### Button States + +```html + + + + + +``` + +--- + +## UI State Checklist + +Before completing any UI component: + +### UI States + +- [ ] Error state handled and shown to user +- [ ] Loading state shown only when no data exists +- [ ] Empty state provided for collections (`@empty` block) +- [ ] Buttons disabled during async operations +- [ ] Buttons show loading indicator when appropriate + +### Data & Mutations + +- [ ] All async operations have error handling +- [ ] All user actions have feedback (toast/visual) +- [ ] Optimistic updates rollback on failure + +### Accessibility + +- [ ] Loading states announced to screen readers +- [ ] Error messages linked to form fields +- [ ] Focus management after state changes + +--- + +## Integration with Other Skills + +- **angular-state-management**: Use Signal stores for state +- **angular**: Apply modern patterns (Signals, @defer) +- **testing-patterns**: Test all UI states diff --git a/skills/angular/SKILL.md b/skills/angular/SKILL.md new file mode 100644 index 00000000..fa377d6a --- /dev/null +++ b/skills/angular/SKILL.md @@ -0,0 +1,763 @@ +--- +name: angular +description: >- + Modern Angular (v20+) expert with deep knowledge of Signals, Standalone + Components, Zoneless applications, SSR/Hydration, and reactive patterns. + Use PROACTIVELY for Angular development, component architecture, state + management, performance optimization, and migration to modern patterns. +--- + +# Angular Expert + +Master modern Angular development with Signals, Standalone Components, Zoneless applications, SSR/Hydration, and the latest reactive patterns. + +## When to Use This Skill + +- Building new Angular applications (v20+) +- Implementing Signals-based reactive patterns +- Creating Standalone Components and migrating from NgModules +- Configuring Zoneless Angular applications +- Implementing SSR, prerendering, and hydration +- Optimizing Angular performance +- Adopting modern Angular patterns and best practices + +## Do Not Use This Skill When + +- Migrating from AngularJS (1.x) → use `angular-migration` skill +- Working with legacy Angular apps that cannot upgrade +- General TypeScript issues → use `typescript-expert` skill + +## Instructions + +1. Assess the Angular version and project structure +2. Apply modern patterns (Signals, Standalone, Zoneless) +3. Implement with proper typing and reactivity +4. Validate with build and tests + +## Safety + +- Always test changes in development before production +- Gradual migration for existing apps (don't big-bang refactor) +- Keep backward compatibility during transitions + +--- + +## Angular Version Timeline + +| Version | Release | Key Features | +| -------------- | ------- | ------------------------------------------------------ | +| **Angular 20** | Q2 2025 | Signals stable, Zoneless stable, Incremental hydration | +| **Angular 21** | Q4 2025 | Signals-first default, Enhanced SSR | +| **Angular 22** | Q2 2026 | Signal Forms, Selectorless components | + +--- + +## 1. Signals: The New Reactive Primitive + +Signals are Angular's fine-grained reactivity system, replacing zone.js-based change detection. + +### Core Concepts + +```typescript +import { signal, computed, effect } from "@angular/core"; + +// Writable signal +const count = signal(0); + +// Read value +console.log(count()); // 0 + +// Update value +count.set(5); // Direct set +count.update((v) => v + 1); // Functional update + +// Computed (derived) signal +const doubled = computed(() => count() * 2); + +// Effect (side effects) +effect(() => { + console.log(`Count changed to: ${count()}`); +}); +``` + +### Signal-Based Inputs and Outputs + +```typescript +import { Component, input, output, model } from "@angular/core"; + +@Component({ + selector: "app-user-card", + standalone: true, + template: ` +
+

{{ name() }}

+ {{ role() }} + +
+ `, +}) +export class UserCardComponent { + // Signal inputs (read-only) + id = input.required(); + name = input.required(); + role = input("User"); // With default + + // Output + select = output(); + + // Two-way binding (model) + isSelected = model(false); +} + +// Usage: +// +``` + +### Signal Queries (ViewChild/ContentChild) + +```typescript +import { + Component, + viewChild, + viewChildren, + contentChild, +} from "@angular/core"; + +@Component({ + selector: "app-container", + standalone: true, + template: ` + + + `, +}) +export class ContainerComponent { + // Signal-based queries + searchInput = viewChild("searchInput"); + items = viewChildren(ItemComponent); + projectedContent = contentChild(HeaderDirective); + + focusSearch() { + this.searchInput()?.nativeElement.focus(); + } +} +``` + +### When to Use Signals vs RxJS + +| Use Case | Signals | RxJS | +| ----------------------- | --------------- | -------------------------------- | +| Local component state | ✅ Preferred | Overkill | +| Derived/computed values | ✅ `computed()` | `combineLatest` works | +| Side effects | ✅ `effect()` | `tap` operator | +| HTTP requests | ❌ | ✅ HttpClient returns Observable | +| Event streams | ❌ | ✅ `fromEvent`, operators | +| Complex async flows | ❌ | ✅ `switchMap`, `mergeMap` | + +--- + +## 2. Standalone Components + +Standalone components are self-contained and don't require NgModule declarations. + +### Creating Standalone Components + +```typescript +import { Component } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { RouterLink } from "@angular/router"; + +@Component({ + selector: "app-header", + standalone: true, + imports: [CommonModule, RouterLink], // Direct imports + template: ` +
+ Home + About +
+ `, +}) +export class HeaderComponent {} +``` + +### Bootstrapping Without NgModule + +```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()], +}); +``` + +### Lazy Loading Standalone Components + +```typescript +// app.routes.ts +import { Routes } from "@angular/router"; + +export const routes: Routes = [ + { + path: "dashboard", + loadComponent: () => + import("./dashboard/dashboard.component").then( + (m) => m.DashboardComponent, + ), + }, + { + path: "admin", + loadChildren: () => + import("./admin/admin.routes").then((m) => m.ADMIN_ROUTES), + }, +]; +``` + +--- + +## 3. Zoneless Angular + +Zoneless applications don't use zone.js, improving performance and debugging. + +### Enabling Zoneless Mode + +```typescript +// main.ts +import { bootstrapApplication } from "@angular/platform-browser"; +import { provideZonelessChangeDetection } from "@angular/core"; +import { AppComponent } from "./app/app.component"; + +bootstrapApplication(AppComponent, { + providers: [provideZonelessChangeDetection()], +}); +``` + +### Zoneless Component Patterns + +```typescript +import { Component, signal, ChangeDetectionStrategy } from "@angular/core"; + +@Component({ + selector: "app-counter", + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
Count: {{ count() }}
+ + `, +}) +export class CounterComponent { + count = signal(0); + + increment() { + this.count.update((v) => v + 1); + // No zone.js needed - Signal triggers change detection + } +} +``` + +### Key Zoneless Benefits + +- **Performance**: No zone.js patches on async APIs +- **Debugging**: Clean stack traces without zone wrappers +- **Bundle size**: Smaller without zone.js (~15KB savings) +- **Interoperability**: Better with Web Components and micro-frontends + +--- + +## 4. Server-Side Rendering & Hydration + +### SSR Setup with Angular CLI + +```bash +ng add @angular/ssr +``` + +### Hydration Configuration + +```typescript +// app.config.ts +import { ApplicationConfig } from "@angular/core"; +import { + provideClientHydration, + withEventReplay, +} from "@angular/platform-browser"; + +export const appConfig: ApplicationConfig = { + providers: [provideClientHydration(withEventReplay())], +}; +``` + +### Incremental Hydration (v20+) + +```typescript +import { Component } from "@angular/core"; + +@Component({ + selector: "app-page", + standalone: true, + template: ` + + + @defer (hydrate on viewport) { + + } + + @defer (hydrate on interaction) { + + } + `, +}) +export class PageComponent {} +``` + +### Hydration Triggers + +| Trigger | When to Use | +| ---------------- | --------------------------------------- | +| `on idle` | Low-priority, hydrate when browser idle | +| `on viewport` | Hydrate when element enters viewport | +| `on interaction` | Hydrate on first user interaction | +| `on hover` | Hydrate when user hovers | +| `on timer(ms)` | Hydrate after specified delay | + +--- + +## 5. Modern Routing Patterns + +### Functional Route Guards + +```typescript +// auth.guard.ts +import { inject } from "@angular/core"; +import { Router, CanActivateFn } from "@angular/router"; +import { AuthService } from "./auth.service"; + +export const authGuard: CanActivateFn = (route, state) => { + const auth = inject(AuthService); + const router = inject(Router); + + if (auth.isAuthenticated()) { + return true; + } + + return router.createUrlTree(["/login"], { + queryParams: { returnUrl: state.url }, + }); +}; + +// Usage in routes +export const routes: Routes = [ + { + path: "dashboard", + loadComponent: () => import("./dashboard.component"), + canActivate: [authGuard], + }, +]; +``` + +### Route-Level Data Resolvers + +```typescript +import { inject } from '@angular/core'; +import { ResolveFn } from '@angular/router'; +import { UserService } from './user.service'; +import { User } from './user.model'; + +export const userResolver: ResolveFn = (route) => { + const userService = inject(UserService); + return userService.getUser(route.paramMap.get('id')!); +}; + +// In routes +{ + path: 'user/:id', + loadComponent: () => import('./user.component'), + resolve: { user: userResolver } +} + +// In component +export class UserComponent { + private route = inject(ActivatedRoute); + user = toSignal(this.route.data.pipe(map(d => d['user']))); +} +``` + +--- + +## 6. Dependency Injection Patterns + +### Modern inject() Function + +```typescript +import { Component, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { UserService } from './user.service'; + +@Component({...}) +export class UserComponent { + // Modern inject() - no constructor needed + private http = inject(HttpClient); + private userService = inject(UserService); + + // Works in any injection context + users = toSignal(this.userService.getUsers()); +} +``` + +### Injection Tokens for Configuration + +```typescript +import { InjectionToken, inject } from "@angular/core"; + +// Define token +export const API_BASE_URL = new InjectionToken("API_BASE_URL"); + +// Provide in config +bootstrapApplication(AppComponent, { + providers: [{ provide: API_BASE_URL, useValue: "https://api.example.com" }], +}); + +// Inject in service +@Injectable({ providedIn: "root" }) +export class ApiService { + private baseUrl = inject(API_BASE_URL); + + get(endpoint: string) { + return this.http.get(`${this.baseUrl}/${endpoint}`); + } +} +``` + +--- + +## 7. State Management Patterns + +### Signal-Based State Service + +```typescript +import { Injectable, signal, computed } from "@angular/core"; + +interface AppState { + user: User | null; + theme: "light" | "dark"; + notifications: Notification[]; +} + +@Injectable({ providedIn: "root" }) +export class StateService { + // Private writable signals + private _user = signal(null); + private _theme = signal<"light" | "dark">("light"); + private _notifications = signal([]); + + // Public read-only computed + readonly user = computed(() => this._user()); + readonly theme = computed(() => this._theme()); + readonly notifications = computed(() => this._notifications()); + readonly unreadCount = computed( + () => this._notifications().filter((n) => !n.read).length, + ); + + // Actions + setUser(user: User | null) { + this._user.set(user); + } + + toggleTheme() { + this._theme.update((t) => (t === "light" ? "dark" : "light")); + } + + addNotification(notification: Notification) { + this._notifications.update((n) => [...n, notification]); + } +} +``` + +### Component Store Pattern with Signals + +```typescript +import { Injectable, signal, computed, inject } from "@angular/core"; +import { HttpClient } from "@angular/common/http"; +import { toSignal } from "@angular/core/rxjs-interop"; + +@Injectable() +export class ProductStore { + private http = inject(HttpClient); + + // State + private _products = signal([]); + private _loading = signal(false); + private _filter = signal(""); + + // Selectors + readonly products = computed(() => this._products()); + readonly loading = computed(() => this._loading()); + readonly filteredProducts = computed(() => { + const filter = this._filter().toLowerCase(); + return this._products().filter((p) => + p.name.toLowerCase().includes(filter), + ); + }); + + // Actions + loadProducts() { + this._loading.set(true); + this.http.get("/api/products").subscribe({ + next: (products) => { + this._products.set(products); + this._loading.set(false); + }, + error: () => this._loading.set(false), + }); + } + + setFilter(filter: string) { + this._filter.set(filter); + } +} +``` + +--- + +## 8. Forms with Signals (Coming in v22+) + +### Current Reactive Forms + +```typescript +import { Component, inject } from "@angular/core"; +import { FormBuilder, Validators, ReactiveFormsModule } from "@angular/forms"; + +@Component({ + selector: "app-user-form", + standalone: true, + imports: [ReactiveFormsModule], + template: ` +
+ + + +
+ `, +}) +export class UserFormComponent { + private fb = inject(FormBuilder); + + form = this.fb.group({ + name: ["", Validators.required], + email: ["", [Validators.required, Validators.email]], + }); + + onSubmit() { + if (this.form.valid) { + console.log(this.form.value); + } + } +} +``` + +### Signal-Aware Form Patterns (Preview) + +```typescript +// Future Signal Forms API (experimental) +import { Component, signal } from '@angular/core'; + +@Component({...}) +export class SignalFormComponent { + name = signal(''); + email = signal(''); + + // Computed validation + isValid = computed(() => + this.name().length > 0 && + this.email().includes('@') + ); + + submit() { + if (this.isValid()) { + console.log({ name: this.name(), email: this.email() }); + } + } +} +``` + +--- + +## 9. Performance Optimization + +### Change Detection Strategies + +```typescript +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + // Only checks when: + // 1. Input signal/reference changes + // 2. Event handler runs + // 3. Async pipe emits + // 4. Signal value changes +}) +``` + +### Defer Blocks for Lazy Loading + +```typescript +@Component({ + template: ` + + + + + @defer (on viewport) { + + } @placeholder { +
+ } @loading (minimum 200ms) { + + } @error { +

Failed to load chart

+ } + ` +}) +``` + +### NgOptimizedImage + +```typescript +import { NgOptimizedImage } from '@angular/common'; + +@Component({ + imports: [NgOptimizedImage], + template: ` + + + + ` +}) +``` + +--- + +## 10. Testing Modern Angular + +### Testing Signal Components + +```typescript +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { CounterComponent } from "./counter.component"; + +describe("CounterComponent", () => { + let component: CounterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CounterComponent], // Standalone import + }).compileComponents(); + + fixture = TestBed.createComponent(CounterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should increment count", () => { + expect(component.count()).toBe(0); + + component.increment(); + + expect(component.count()).toBe(1); + }); + + it("should update DOM on signal change", () => { + component.count.set(5); + fixture.detectChanges(); + + const el = fixture.nativeElement.querySelector(".count"); + expect(el.textContent).toContain("5"); + }); +}); +``` + +### Testing with Signal Inputs + +```typescript +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ComponentRef } from "@angular/core"; +import { UserCardComponent } from "./user-card.component"; + +describe("UserCardComponent", () => { + let fixture: ComponentFixture; + let componentRef: ComponentRef; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UserCardComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(UserCardComponent); + componentRef = fixture.componentRef; + + // Set signal inputs via setInput + componentRef.setInput("id", "123"); + componentRef.setInput("name", "John Doe"); + + fixture.detectChanges(); + }); + + it("should display user name", () => { + const el = fixture.nativeElement.querySelector("h3"); + expect(el.textContent).toContain("John Doe"); + }); +}); +``` + +--- + +## Best Practices Summary + +| Pattern | ✅ Do | ❌ Don't | +| -------------------- | ------------------------------ | ------------------------------- | +| **State** | Use Signals for local state | Overuse RxJS for simple state | +| **Components** | Standalone with direct imports | Bloated SharedModules | +| **Change Detection** | OnPush + Signals | Default CD everywhere | +| **Lazy Loading** | `@defer` and `loadComponent` | Eager load everything | +| **DI** | `inject()` function | Constructor injection (verbose) | +| **Inputs** | `input()` signal function | `@Input()` decorator (legacy) | +| **Zoneless** | Enable for new projects | Force on legacy without testing | + +--- + +## Resources + +- [Angular.dev Documentation](https://angular.dev) +- [Angular Signals Guide](https://angular.dev/guide/signals) +- [Angular SSR Guide](https://angular.dev/guide/ssr) +- [Angular Update Guide](https://angular.dev/update-guide) +- [Angular Blog](https://blog.angular.dev) + +--- + +## Common Troubleshooting + +| Issue | Solution | +| ------------------------------ | --------------------------------------------------- | +| Signal not updating UI | Ensure `OnPush` + call signal as function `count()` | +| Hydration mismatch | Check server/client content consistency | +| Circular dependency | Use `inject()` with `forwardRef` | +| Zoneless not detecting changes | Trigger via signal updates, not mutations | +| SSR fetch fails | Use `TransferState` or `withFetch()` |