--- 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. risk: safe source: self --- # 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 | Async Waterfalls | CRITICAL | RxJS patterns, SSR preloading | | 3 | Bundle Optimization | CRITICAL | Lazy loading, tree shaking | | 4 | Rendering Performance | HIGH | @defer, trackBy, virtualization | | 5 | Server-Side Rendering | HIGH | Hydration, prerendering | | 6 | Template Optimization | MEDIUM | Control flow, pipes | | 7 | State Management | MEDIUM | Signal patterns, selectors | | 8 | 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. Async Operations & Waterfalls (CRITICAL) ### Eliminate Sequential Data Fetching ```typescript // WRONG - Nested subscriptions create waterfalls this.route.params.subscribe((params) => { // 1. Wait for params this.userService.getUser(params.id).subscribe((user) => { // 2. Wait for user this.postsService.getPosts(user.id).subscribe((posts) => { // 3. Wait for posts }); }); }); // CORRECT - Parallel execution with forkJoin forkJoin({ user: this.userService.getUser(id), posts: this.postsService.getPosts(id), }).subscribe((data) => { // Fetched in parallel }); // CORRECT - Flatten dependent calls with switchMap this.route.params .pipe( map((p) => p.id), switchMap((id) => this.userService.getUser(id)), ) .subscribe(); ``` ### Avoid Client-Side Waterfalls in SSR ```typescript // CORRECT - Use resolvers or blocking hydration for critical data export const route: Route = { path: "profile/:id", resolve: { data: profileResolver }, // Fetched on server before navigation component: ProfileComponent, }; // WRONG - Component fetches data on init class ProfileComponent implements OnInit { ngOnInit() { // Starts ONLY after JS loads and component renders this.http.get("/api/profile").subscribe(); } } ``` --- ## 3. 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'; ``` --- ## 4. 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) ); } ``` --- ## 5. 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); } }), ); } } ``` --- ## 6. 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) { ... } ``` --- ## 7. 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 } ``` --- ## 8. 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)