
Angular Best Practices V20
- 328 installs
- 19 repo stars
- Updated February 3, 2026
- develite98/angular-best-practices
Codifies modern Angular v20 best practices (standalone components, signals, new control flow) so an AI agent writes current, idiomatic Angular code.
About
angular-best-practices-v20 codifies best practices for modern Angular v20, covering standalone components, signals, the current control-flow syntax, and up-to-date structural conventions. A solo developer reaches for it when building or refactoring an Angular v20 app with an AI coding agent, so generated code follows the latest official patterns instead of outdated module-and-decorator idioms.
- Standalone components and signals patterns
- Modern Angular v20 control-flow syntax
- Up-to-date structural conventions
Angular Best Practices V20 by the numbers
- 328 all-time installs (skills.sh)
- Ranked #721 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/develite98/angular-best-practices --skill angular-best-practices-v20Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 328 |
|---|---|
| repo stars | ★ 19 |
| Last updated | February 3, 2026 |
| Repository | develite98/angular-best-practices ↗ |
What it does
Codifies modern Angular v20 best practices (standalone components, signals, new control flow) so an AI agent writes current, idiomatic Angular code.
Who is it for?
Solo devs writing production Angular code with an AI agent
Files
Angular Best Practices (v20+)
Comprehensive performance optimization guide for Angular 20+ applications with modern features like Signals, httpResource, signal inputs/outputs, @defer blocks, and native control flow syntax. Contains 35+ rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
When to Apply
Reference these guidelines when:
- Writing new Angular 20+ components
- Using Signals for reactive state (signal, computed, linkedSignal, effect)
- Using httpResource/resource for data fetching
- Using signal inputs/outputs instead of decorators
- Implementing @defer for lazy loading
- Using native control flow (@if, @for, @switch)
- Implementing SSR with incremental hydration
- Reviewing code for performance issues
Key Features (v20+)
- Signals - Fine-grained reactivity (signal, computed, linkedSignal, effect)
- httpResource - Signal-based HTTP with automatic loading states
- Signal inputs/outputs - input(), output() replacing decorators
- @defer - Template-level lazy loading with hydration triggers
- @for / @if - Native control flow with required track
- Standalone by default - No standalone: true needed
- Host bindings - host object instead of @HostBinding decorators
- Functional interceptors - Simpler HTTP interceptors
- takeUntilDestroyed - Built-in subscription cleanup
- Incremental hydration - @defer (hydrate on ...) for SSR
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Change Detection | CRITICAL | change- |
| 2 | Bundle & Lazy Loading | CRITICAL | bundle- |
| 3 | RxJS Optimization | HIGH | rxjs- |
| 4 | Template Performance | HIGH | template- |
| 5 | Dependency Injection | MEDIUM-HIGH | di- |
| 6 | HTTP & Caching | MEDIUM | http- |
| 7 | Forms Optimization | MEDIUM | forms- |
| 8 | General Performance | LOW-MEDIUM | ssr- |
Quick Reference
1. Change Detection (CRITICAL)
change-signals- Use Signals instead of BehaviorSubject for reactive statechange-onpush- Use OnPush change detection strategychange-detach-reattach- Detach change detector for heavy operationschange-run-outside-zone- Run non-UI code outside NgZonecomponent-signal-io- Use signal inputs/outputs instead of @Input/@Output decoratorssignal-computed-pure- Keep computed() pure, no side effectssignal-effect-patterns- Use effect() correctly, avoid anti-patternssignal-linkedsignal- Use linkedSignal for derived + writable state
2. Bundle & Lazy Loading (CRITICAL)
bundle-standalone- Use standalone components (default in v20+)bundle-lazy-routes- Lazy load routes with loadComponent/loadChildrenbundle-defer- Use @defer blocks for heavy componentsbundle-preload- Preload routes on hover/focus for perceived speedbundle-no-barrel-imports- Avoid barrel files, use direct imports
3. RxJS Optimization (HIGH)
rxjs-async-pipe- Use async pipe instead of manual subscriptionsrxjs-takeuntil- Use takeUntilDestroyed for automatic cleanuprxjs-share-replay- Share observables to avoid duplicate requestsrxjs-operators- Use efficient RxJS operatorsrxjs-mapping-operators- Use correct mapping operators (switchMap vs exhaustMap)rxjs-no-nested-subscribe- Avoid nested subscriptions
4. Template Performance (HIGH)
template-trackby- Use track function in @for loops (required in v20+)template-pure-pipes- Use pure pipes for expensive transformationstemplate-ng-optimized-image- Use NgOptimizedImage for image optimizationtemplate-no-function-calls- Avoid function calls in templatestemplate-virtual-scroll- Use virtual scrolling for large lists
5. Dependency Injection (MEDIUM-HIGH)
di-provided-in-root- Use providedIn: 'root' for singleton servicesdi-injection-token- Use InjectionToken for non-class dependenciesdi-factory-providers- Use factory providers for complex initializationdirective-host-composition- Use hostDirectives for composition
6. HTTP & Caching (MEDIUM)
http-resource- Use httpResource/resource for signal-based data fetchinghttp-interceptors- Use functional interceptors for cross-cutting concernshttp-transfer-state- Use TransferState for SSR hydrationrouting-signal-inputs- Use signal-based route inputs
7. Forms Optimization (MEDIUM)
forms-reactive- Use reactive forms with typed FormGroup
8. General Performance (LOW-MEDIUM)
ssr-hydration- Use incremental hydration with @defer (hydrate on ...)perf-memory-leaks- Prevent memory leaks (timers, listeners, subscriptions)perf-web-workers- Offload heavy computation to Web Workersarch-smart-dumb-components- Use Smart/Dumb component pattern
How to Use
Read individual rule files for detailed explanations and code examples:
rules/change-signals.md
rules/bundle-defer.md
rules/http-resource.mdEach rule file contains:
- Brief explanation of why it matters
- Incorrect code example with explanation
- Correct code example with explanation
- Additional context and references
Full Compiled Document
For the complete guide with all rules expanded: AGENTS.md
{
"version": "2.0.0",
"organization": "Community",
"date": "January 2026",
"abstract": "Performance optimization guide for Angular 20+ applications with modern features including Signals, linkedSignal, httpResource, @defer blocks, standalone components (default), signal inputs/outputs, and native control flow syntax (@if, @for). Contains rules prioritized by impact for AI-assisted code generation and refactoring.",
"angularVersion": "Angular 20+",
"references": [
"https://angular.dev",
"https://angular.dev/guide/signals",
"https://angular.dev/guide/defer",
"https://angular.dev/guide/templates/control-flow",
"https://angular.dev/guide/image-optimization"
]
}
Sections (Angular 20+)
This file defines all sections for Angular 20+ with modern features like Signals, @defer, and new control flow syntax.
---
1. Change Detection (change)
Impact: CRITICAL Description: Change detection is the #1 performance factor in Angular. Using OnPush strategy, Signals, and proper zone management can dramatically reduce unnecessary checks.
2. Bundle & Lazy Loading (bundle)
Impact: CRITICAL Description: Standalone components, @defer blocks, and lazy loading improve Time to Interactive. Angular 20+ defaults to standalone.
3. RxJS Optimization (rxjs)
Impact: HIGH Description: Proper RxJS usage with takeUntilDestroyed, async pipe, and efficient operators prevents memory leaks and reduces computations.
4. Template Performance (template)
Impact: HIGH Description: New control flow (@for with track, @if) and pure pipes optimize rendering. NgOptimizedImage improves Core Web Vitals.
5. Dependency Injection (di)
Impact: MEDIUM-HIGH Description: Proper DI with providedIn, InjectionToken, and factory providers enables tree-shaking and testability.
6. HTTP & Caching (http)
Impact: MEDIUM Description: Functional interceptors, HTTP cache transfer for SSR, and caching strategies reduce network requests.
7. Forms Optimization (forms)
Impact: MEDIUM Description: Typed reactive forms with NonNullableFormBuilder provide compile-time safety and better DX.
8. General Performance (perf)
Impact: LOW-MEDIUM Description: Web Workers and additional optimization patterns for specific use cases.
Defer Non-Critical Third-Party Scripts
Analytics, error tracking, chat widgets, and other non-critical third-party scripts don't need to block initial render. Use @defer to load them after hydration completes.
Incorrect (blocks initial bundle):
// ❌ Analytics component loads in main bundle
import { AnalyticsComponent } from './analytics.component';
import { ChatWidgetComponent } from './chat-widget.component';
import { ErrorTrackingComponent } from './error-tracking.component';
@Component({
selector: 'app-root',
template: `
<app-header />
<router-outlet />
<app-footer />
<!-- These load immediately, blocking TTI -->
<app-analytics />
<app-chat-widget />
<app-error-tracking />
`,
imports: [
AnalyticsComponent, // Included in main bundle
ChatWidgetComponent, // Included in main bundle
ErrorTrackingComponent // Included in main bundle
]
})
export class AppComponent {}Correct (defer with @defer):
@Component({
selector: 'app-root',
template: `
<app-header />
<router-outlet />
<app-footer />
<!-- ✅ Loads AFTER hydration completes -->
@defer (on idle) {
<app-analytics />
}
@defer (on idle) {
<app-chat-widget />
}
@defer (on idle) {
<app-error-tracking />
}
`,
imports: [HeaderComponent, FooterComponent]
// Analytics, ChatWidget, ErrorTracking NOT in imports - loaded dynamically
})
export class AppComponent {}With timer delay:
@Component({
selector: 'app-root',
template: `
<router-outlet />
<!-- Load after 3 seconds idle time -->
@defer (on idle; when isHydrated) {
<app-analytics />
<app-intercom-chat />
} @placeholder {
<!-- Nothing shown while waiting -->
}
`
})
export class AppComponent {
isHydrated = signal(false);
constructor() {
afterNextRender(() => {
this.isHydrated.set(true);
});
}
}For heavy libraries (charts, maps):
@Component({
selector: 'app-dashboard',
template: `
<div class="metrics">
<app-kpi-cards [data]="kpiData()" />
</div>
<!-- Heavy chart library deferred until visible -->
@defer (on viewport) {
<app-revenue-chart [data]="chartData()" />
} @placeholder {
<div class="chart-skeleton">
<div class="skeleton-bar"></div>
</div>
} @loading (minimum 200ms) {
<app-spinner />
}
<!-- Map only loads when user scrolls to it -->
@defer (on viewport) {
<app-location-map [markers]="locations()" />
} @placeholder {
<div class="map-placeholder">
<img src="assets/map-preview.webp" alt="Map preview" />
</div>
}
`
})
export class DashboardComponent {}Third-party script loading pattern:
// Service to load third-party scripts on demand
@Injectable({ providedIn: 'root' })
export class ThirdPartyService {
private loaded = new Map<string, Promise<void>>();
loadScript(src: string): Promise<void> {
if (this.loaded.has(src)) {
return this.loaded.get(src)!;
}
const promise = new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load ${src}`));
document.body.appendChild(script);
});
this.loaded.set(src, promise);
return promise;
}
}
// Analytics component that loads external script
@Component({
selector: 'app-analytics',
template: ``,
standalone: true
})
export class AnalyticsComponent {
private thirdParty = inject(ThirdPartyService);
constructor() {
afterNextRender(async () => {
await this.thirdParty.loadScript('https://analytics.example.com/script.js');
window.analytics?.init({ key: environment.analyticsKey });
});
}
}SSR hydration-aware loading:
@Component({
selector: 'app-root',
template: `
<router-outlet />
<!-- Only load on client, after hydration -->
@defer (on idle; hydrate never) {
<app-client-only-widget />
}
<!-- Prefetch during idle, hydrate on interaction -->
@defer (on interaction; prefetch on idle; hydrate on interaction) {
<app-feedback-widget />
}
`
})
export class AppComponent {}Priority guidelines:
| Third-party | Priority | Defer Strategy |
|---|---|---|
| Analytics | Low | @defer (on idle) after 2-3s |
| Error tracking | Low | @defer (on idle) |
| Chat widget | Low | @defer (on idle) or (on viewport) |
| Social embeds | Low | @defer (on viewport) |
| Maps | Medium | @defer (on viewport) |
| Video players | Medium | @defer (on viewport) |
| A/B testing | High | Load in main bundle (affects content) |
| Auth providers | High | Load in main bundle (blocks UX) |
Why it matters:
- Third-party scripts often add 100KB+ to initial bundle
- Analytics don't affect user experience - defer them
- Faster Time to Interactive (TTI) improves Core Web Vitals
- Users can interact sooner with the critical UI
Reference: Angular Deferrable Views
Use @defer for Lazy Loading Components
@defer delays loading of heavy components until a trigger condition is met, reducing initial bundle size without route changes.
Incorrect (Heavy components loaded immediately):
@Component({
selector: 'app-dashboard',
imports: [HeavyChartComponent, DataTableComponent],
template: `
<h1>Dashboard</h1>
<!-- Chart library loaded even if user never scrolls down -->
<app-heavy-chart [data]="chartData" />
<!-- Large table always in initial bundle -->
<app-data-table [rows]="tableData" />
`
})
export class DashboardComponent {}Correct (Defer loading until needed):
@Component({
selector: 'app-dashboard',
imports: [HeavyChartComponent, DataTableComponent],
template: `
<h1>Dashboard</h1>
@defer (on viewport) {
<app-heavy-chart [data]="chartData" />
} @placeholder {
<div class="chart-skeleton">Loading chart...</div>
}
@defer (on interaction) {
<app-data-table [rows]="tableData" />
} @placeholder {
<button>Click to load data table</button>
}
`
})
export class DashboardComponent {}Why it matters:
on viewport- Loads when element enters viewport (scroll-triggered)on interaction- Loads on click/focus (user-triggered)on idle- Loads when browser is idle (background loading)@placeholdershows fallback content until loaded
Reference: Angular Defer
Lazy Load Routes with loadComponent
Lazy loading splits your application into smaller chunks loaded on demand. Use loadComponent for standalone components to reduce initial bundle size.
Incorrect (Eagerly loaded routes):
import { DashboardComponent } from './dashboard/dashboard.component';
import { SettingsComponent } from './settings/settings.component';
import { ReportsComponent } from './reports/reports.component';
export const routes: Routes = [
{ path: '', component: DashboardComponent },
{ path: 'settings', component: SettingsComponent },
{ path: 'reports', component: ReportsComponent }
// All components loaded upfront, even if never visited
];Correct (Lazy loaded routes):
export const routes: Routes = [
{
path: '',
loadComponent: () =>
import('./dashboard/dashboard.component').then(m => m.DashboardComponent)
},
{
path: 'settings',
loadComponent: () =>
import('./settings/settings.component').then(m => m.SettingsComponent)
},
{
path: 'reports',
loadChildren: () =>
import('./reports/reports.routes').then(m => m.REPORTS_ROUTES)
}
];Why it matters:
- Initial bundle only includes code for first route
- Other routes downloaded on navigation
loadComponentfor single componentsloadChildrenfor route groups
Reference: Angular Lazy Loading
Use Preload Strategies for Lazy Modules
Preloading downloads lazy-loaded modules in the background after initial load, making subsequent navigation instant.
Incorrect (No preloading causes navigation delay):
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes)
// No preloading - modules load on demand
// User experiences delay on first navigation
]
};Correct (Preload all modules after initial load):
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withPreloading(PreloadAllModules)
)
]
};Why it matters:
PreloadAllModulesloads all routes after initial render- Navigation to lazy routes becomes instant
- Initial load is not affected (preloading happens after)
- Custom strategies can preload selectively
Reference: Angular Preloading
Use Standalone Components
Standalone components don't require NgModules, enabling better tree-shaking and granular lazy loading. In Angular v19+, components are standalone by default.
Incorrect (NgModule-based with implicit dependencies):
@NgModule({
declarations: [UserListComponent, UserDetailComponent],
imports: [CommonModule, SharedModule],
exports: [UserListComponent]
})
export class UserModule {}
@Component({
selector: 'app-user-list',
template: `...`
})
export class UserListComponent {}
// Dependencies come from module - not explicitCorrect (Standalone with explicit imports):
@Component({
selector: 'app-user-list',
// No standalone: true needed in v19+
imports: [RouterLink, UserAvatarComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (user of users(); track user.id) {
<app-user-avatar [user]="user" />
<a [routerLink]="['/users', user.id]">{{ user.name }}</a>
}
`
})
export class UserListComponent {
private userService = inject(UserService);
users = toSignal(this.userService.getUsers(), { initialValue: [] });
}Why it matters:
- Dependencies explicit in component's
importsarray - Better tree-shaking (unused components excluded)
- No NgModule boilerplate needed
- Components are standalone by default in v19+
Reference: Angular Standalone Components
Use Angular Signals for Reactive State
Signals provide fine-grained reactivity where only components reading a signal are updated when it changes. This eliminates the need for manual ChangeDetectorRef calls.
Incorrect (Manual change detection with OnPush):
@Component({
selector: 'app-counter',
template: `
<p>Count: {{ count }}</p>
<p>Double: {{ count * 2 }}</p>
<button (click)="increment()">+</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent {
count = 0;
constructor(private cdr: ChangeDetectorRef) {}
increment() {
this.count++;
this.cdr.markForCheck(); // Manual trigger required
}
}Correct (Signals with automatic change detection):
@Component({
selector: 'app-counter',
template: `
<p>Count: {{ count() }}</p>
<p>Double: {{ doubleCount() }}</p>
<button (click)="increment()">+</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent {
count = signal(0);
doubleCount = computed(() => this.count() * 2);
increment() {
this.count.update(c => c + 1);
// No markForCheck needed - signals handle it automatically
}
}Why it matters:
- Signals automatically notify Angular when values change
computed()creates derived values that update reactively- No manual
ChangeDetectorRefmanagement needed - Works seamlessly with OnPush change detection
Reference: Angular Signals
Use Signal Inputs and Outputs
Signal inputs (input()) and outputs (output()) replace @Input() and @Output() decorators, providing better type inference and reactive tracking without OnChanges.
Incorrect (Decorator-based with OnChanges):
@Component({
selector: 'app-user-card',
template: `
<h2>{{ name }}</h2>
<p>{{ email }}</p>
<button (click)="onSelect()">Select</button>
`
})
export class UserCardComponent implements OnChanges {
@Input() name!: string;
@Input() email = '';
@Output() selected = new EventEmitter<string>();
ngOnChanges(changes: SimpleChanges) {
if (changes['name']) {
console.log('Name changed:', this.name);
}
}
onSelect() {
this.selected.emit(this.name);
}
}Correct (Signal inputs with effect):
@Component({
selector: 'app-user-card',
template: `
<h2>{{ name() }}</h2>
<p>{{ email() }}</p>
<button (click)="handleClick()">Select</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserCardComponent {
name = input.required<string>();
email = input('');
selected = output<string>();
constructor() {
effect(() => {
console.log('Name changed:', this.name());
});
}
handleClick() {
this.selected.emit(this.name());
}
}Why it matters:
input.required<T>()enforces required inputs at compile timeinput(defaultValue)provides type-inferred optional inputseffect()replacesngOnChangesfor reacting to input changes- Signals integrate with OnPush for optimal performance
Reference: Angular Signal Inputs
Use Factory Providers for Complex Setup
Factory providers allow conditional service creation with access to other dependencies via inject() inside the factory function.
Incorrect (Complex logic in constructor):
@Injectable({ providedIn: 'root' })
export class StorageService {
private storage: Storage;
constructor() {
// Complex logic in constructor - hard to test
if (typeof window !== 'undefined' && window.localStorage) {
this.storage = window.localStorage;
} else {
this.storage = new MemoryStorage();
}
}
}Correct (Factory with inject()):
export abstract class StorageService {
abstract getItem(key: string): string | null;
abstract setItem(key: string, value: string): void;
}
export class LocalStorageService extends StorageService {
getItem(key: string) { return localStorage.getItem(key); }
setItem(key: string, value: string) { localStorage.setItem(key, value); }
}
export class MemoryStorageService extends StorageService {
private store = new Map<string, string>();
getItem(key: string) { return this.store.get(key) ?? null; }
setItem(key: string, value: string) { this.store.set(key, value); }
}
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
{
provide: StorageService,
useFactory: () => {
const platformId = inject(PLATFORM_ID);
return isPlatformBrowser(platformId)
? new LocalStorageService()
: new MemoryStorageService();
}
}
]
};Why it matters:
- Use
inject()inside factory for dependencies - Conditional service creation based on environment
- Clean separation of implementations
- Easy to test each implementation independently
Reference: Angular Factory Providers
Use InjectionToken for Type-Safe Configuration
InjectionToken provides type-safe dependency injection for non-class values like configuration objects and feature flags.
Incorrect (String tokens lose type safety):
providers: [
{ provide: 'API_URL', useValue: 'https://api.example.com' }
]
@Injectable({ providedIn: 'root' })
export class ApiService {
private apiUrl = inject('API_URL' as any); // No type safety
}Correct (InjectionToken with inject()):
// tokens.ts
export interface AppConfig {
apiUrl: string;
timeout: number;
}
export const APP_CONFIG = new InjectionToken<AppConfig>('app.config');
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
{
provide: APP_CONFIG,
useValue: { apiUrl: 'https://api.example.com', timeout: 5000 }
}
]
};
// api.service.ts
@Injectable({ providedIn: 'root' })
export class ApiService {
private config = inject(APP_CONFIG); // Fully typed as AppConfig
}Why it matters:
- Full type safety with
inject() - Compile-time checking for configuration values
- Easy to test by providing mock tokens
- Self-documenting code
Reference: Angular InjectionToken
Use providedIn root for Tree-Shaking
Services with providedIn: 'root' are tree-shakeable - if no component injects them, they're excluded from the bundle.
Incorrect (Service always in bundle):
@Injectable()
export class UserService {}
@NgModule({
providers: [UserService] // Always in bundle, even if unused
})
export class UserModule {}Correct (Tree-shakeable with inject()):
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
getUsers(): Observable<User[]> {
return this.http.get<User[]>('/api/users');
}
}
// No providers array needed - just inject where used
@Component({...})
export class UserListComponent {
private userService = inject(UserService);
users = toSignal(this.userService.getUsers(), { initialValue: [] });
}Why it matters:
- Unused services excluded from bundle
- No need to add to providers arrays
- Use
inject()function for cleaner dependency injection - Works with signals and OnPush change detection
Reference: Angular Dependency Injection
Use Host Directives for Behavior Composition
Host directives compose reusable behaviors into components without inheritance, promoting composition and keeping components focused.
Incorrect (Repeated behavior across components):
@Component({
selector: 'app-button',
template: `<ng-content />`
})
export class ButtonComponent {
@HostBinding('class.focused') isFocused = false;
@HostBinding('class.disabled') isDisabled = false;
@HostListener('focus') onFocus() { this.isFocused = true; }
@HostListener('blur') onBlur() { this.isFocused = false; }
}
@Component({
selector: 'app-card',
template: `<ng-content />`
})
export class CardComponent {
// Same focus/disable logic duplicated...
@HostBinding('class.focused') isFocused = false;
@HostBinding('class.disabled') isDisabled = false;
}Correct (Reusable behavior directive):
@Directive({
selector: '[focusable]',
host: {
'tabindex': '0',
'(focus)': 'onFocus()',
'(blur)': 'onBlur()',
'[class.focused]': 'isFocused()'
}
})
export class FocusableDirective {
isFocused = signal(false);
onFocus() { this.isFocused.set(true); }
onBlur() { this.isFocused.set(false); }
}
@Component({
selector: 'app-button',
hostDirectives: [FocusableDirective],
template: `<ng-content />`
})
export class ButtonComponent {}
@Component({
selector: 'app-card',
hostDirectives: [FocusableDirective],
template: `<ng-content />`
})
export class CardComponent {}Why it matters:
- Behaviors defined once, reused everywhere
hostDirectivesarray composes multiple behaviors- Inputs/outputs can be exposed via directive configuration
- No inheritance hierarchy needed
Reference: Angular Host Directives
Use Reactive Forms for Complex Forms
Reactive forms provide synchronous access to form state, making them easier to test and offering better control over validation.
Incorrect (Template-driven with complex validation):
@Component({
template: `
<form #userForm="ngForm" (ngSubmit)="onSubmit()">
<input [(ngModel)]="user.email" name="email" required email />
<input [(ngModel)]="user.password" name="password" required />
<input [(ngModel)]="user.confirmPassword" name="confirmPassword" />
<!-- Complex validation in template -->
@if (userForm.controls['password']?.value !== userForm.controls['confirmPassword']?.value) {
<div>Passwords don't match</div>
}
</form>
`
})
export class RegisterComponent {
user = { email: '', password: '', confirmPassword: '' };
}Correct (Reactive form with typed controls):
@Component({
imports: [ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email" />
<input type="password" formControlName="password" />
<input type="password" formControlName="confirmPassword" />
@if (form.errors?.['passwordMismatch']) {
<span class="error">Passwords don't match</span>
}
<button [disabled]="form.invalid">Submit</button>
</form>
`
})
export class RegisterComponent {
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required]
}, {
validators: [this.passwordMatchValidator]
});
passwordMatchValidator(group: FormGroup): ValidationErrors | null {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password === confirm ? null : { passwordMismatch: true };
}
onSubmit() {
if (this.form.valid) {
const { email, password } = this.form.getRawValue();
}
}
}Why it matters:
- Typed form values with
NonNullableFormBuilder - Cross-field validation in component logic
- Synchronous access to form state
- Easy to test without template
Reference: Angular Reactive Forms
Use Functional HTTP Interceptors
Functional interceptors are simpler functions that replace class-based interceptors, with better tree-shaking and no boilerplate.
Incorrect (Class-based interceptor):
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private authService: AuthService) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const token = this.authService.getToken();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
}
return next.handle(req);
}
}
// Registration requires verbose provider config
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
]Correct (Functional interceptor):
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const token = authService.getToken();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
}
return next(req);
};
// app.config.ts - Clean registration
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([authInterceptor])
)
]
};Why it matters:
- Just a function, no class boilerplate
- Use
inject()to get dependencies - Clean array-based registration
- Automatically applies to
httpResource()calls
Reference: Angular HTTP Interceptors
Use httpResource for Signal-Based HTTP
httpResource() provides automatic loading/error states and reactive refetching when dependencies change, eliminating manual state management boilerplate.
Incorrect (Manual loading state management):
@Component({
template: `
@if (loading) {
<p>Loading...</p>
} @else if (error) {
<p>Error: {{ error }}</p>
} @else {
<p>{{ user?.name }}</p>
}
`
})
export class UserComponent implements OnInit {
user: User | null = null;
loading = false;
error: string | null = null;
constructor(private http: HttpClient) {}
ngOnInit() {
this.loading = true;
this.http.get<User>('/api/users/1').subscribe({
next: (user) => {
this.user = user;
this.loading = false;
},
error: (err) => {
this.error = err.message;
this.loading = false;
}
});
}
}Correct (httpResource with automatic state):
@Component({
template: `
@if (userResource.isLoading()) {
<p>Loading...</p>
} @else if (userResource.error()) {
<p>Error: {{ userResource.error()?.message }}</p>
<button (click)="userResource.reload()">Retry</button>
} @else if (userResource.hasValue()) {
<h1>{{ userResource.value().name }}</h1>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserComponent {
userId = signal('123');
// Auto-refetches when userId changes
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
}Why it matters:
- Eliminates loading/error state boilerplate
- Automatically refetches when signal dependencies change
- Built-in
reload()for retry functionality - Type-safe access via
value(),error(),isLoading()
Reference: Angular HTTP Resource
Use TransferState for SSR Hydration
Without TransferState, HTTP requests made on the server are repeated on the client during hydration. TransferState transfers server responses to the client, avoiding duplicates.
Incorrect (Duplicate requests during hydration):
@Component({...})
export class ProductListComponent implements OnInit {
products$!: Observable<Product[]>;
constructor(private http: HttpClient) {}
ngOnInit() {
// Runs on server AND client = 2 identical requests
this.products$ = this.http.get<Product[]>('/api/products');
}
}Correct (Enable HTTP cache transfer):
// app.config.ts
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withFetch()),
provideClientHydration(
withHttpTransferCacheOptions({
includePostRequests: true
})
)
]
};
// component.ts - No changes needed
@Component({...})
export class ProductListComponent {
products$ = inject(HttpClient).get<Product[]>('/api/products');
// Response transferred from server to client automatically
}Why it matters:
- Server response cached and transferred to client
- No duplicate HTTP requests during hydration
- Faster initial page interactivity
- Works automatically with HttpClient
Reference: Angular SSR Hydration
Use Signal Inputs for Route Parameters
With withComponentInputBinding(), route parameters are automatically bound to component inputs. Combined with signal inputs, this eliminates manual ActivatedRoute subscriptions.
Incorrect (Manual route parameter subscription):
@Component({
template: `<h1>User {{ userId }}</h1>`
})
export class UserDetailComponent implements OnInit, OnDestroy {
userId: string | null = null;
private subscription?: Subscription;
constructor(private route: ActivatedRoute) {}
ngOnInit() {
this.subscription = this.route.paramMap.subscribe((params) => {
this.userId = params.get('id');
});
}
ngOnDestroy() {
this.subscription?.unsubscribe();
}
}Correct (Signal input for route params):
// app.config.ts - Enable input binding
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withComponentInputBinding())
]
};
// Route: { path: 'users/:id', component: UserDetailComponent }
@Component({
template: `<h1>User {{ id() }}</h1>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserDetailComponent {
id = input.required<string>(); // Route param auto-bound
userId = computed(() => parseInt(this.id(), 10));
}Why it matters:
- No manual
ActivatedRoutesubscription management - Route params, query params, and resolver data all become inputs
- Reactive updates when route changes
- Clean teardown handled automatically
Reference: Angular Routing
Use Async Pipe Instead of Manual Subscribe
The async pipe automatically subscribes and unsubscribes from observables, preventing memory leaks and working seamlessly with OnPush change detection.
Incorrect (Manual subscription with leak potential):
@Component({
template: `
@if (user) {
<h1>{{ user.name }}</h1>
}
`
})
export class UserProfileComponent implements OnInit, OnDestroy {
user: User | null = null;
private subscription!: Subscription;
constructor(private userService: UserService) {}
ngOnInit() {
this.subscription = this.userService.getCurrentUser()
.subscribe(user => this.user = user);
}
ngOnDestroy() {
this.subscription.unsubscribe(); // Easy to forget
}
}Correct (Async pipe handles lifecycle):
@Component({
imports: [AsyncPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (user$ | async; as user) {
<h1>{{ user.name }}</h1>
}
`
})
export class UserProfileComponent {
user$ = inject(UserService).getCurrentUser();
// No manual subscribe/unsubscribe needed
}Why it matters:
- No manual
Subscriptionmanagement - No
ngOnDestroycleanup needed - Works perfectly with OnPush change detection
- Declarative and testable
Reference: Angular Async Pipe
Use takeUntilDestroyed for Cleanup
takeUntilDestroyed() automatically unsubscribes when the component is destroyed, eliminating manual cleanup boilerplate.
Incorrect (Manual Subject-based cleanup):
@Component({...})
export class DataComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
ngOnInit() {
this.dataService.getData()
.pipe(takeUntil(this.destroy$))
.subscribe(data => this.processData(data));
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
// Boilerplate, easy to forget
}
}Correct (takeUntilDestroyed handles cleanup):
@Component({...})
export class DataComponent {
constructor() {
// In constructor, DestroyRef is auto-injected
this.dataService.getData()
.pipe(takeUntilDestroyed())
.subscribe(data => this.processData(data));
}
}
// Or outside constructor:
export class DataComponent implements OnInit {
private destroyRef = inject(DestroyRef);
ngOnInit() {
this.dataService.getData()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(data => this.processData(data));
}
}Why it matters:
- No
ngOnDestroyboilerplate needed - No manual
Subjectmanagement - Works with
DestroyRefoutside constructor toSignal()is even cleaner when possible
Reference: Angular takeUntilDestroyed
Keep computed() Pure - No Side Effects
computed() signals must be pure functions that only transform input values and return results. Angular may call computed() multiple times during optimization, causing unintended side effects to run repeatedly.
Incorrect (Side effects in computed):
// ❌ Side effects: logging, DOM manipulation, HTTP calls
@Component({...})
export class ProductListComponent {
products = signal<Product[]>([]);
// BAD: Console log runs multiple times unexpectedly
totalPrice = computed(() => {
console.log('Calculating total...'); // Side effect!
return this.products().reduce((sum, p) => sum + p.price, 0);
});
// BAD: Mutating external state
processedProducts = computed(() => {
this.analyticsService.track('products-viewed'); // Side effect!
return this.products().filter(p => p.active);
});
// BAD: Async operations (will NOT work)
enrichedProducts = computed(async () => { // DON'T DO THIS
const products = this.products();
return await this.enrichData(products); // Signals are synchronous!
});
}Correct (Pure computed, effects for side effects):
@Component({...})
export class ProductListComponent {
products = signal<Product[]>([]);
// ✅ Pure computed - only derives new value
totalPrice = computed(() =>
this.products().reduce((sum, p) => sum + p.price, 0)
);
// ✅ Pure computed - filter is a pure operation
activeProducts = computed(() =>
this.products().filter(p => p.active)
);
// ✅ Use effect() for side effects
constructor() {
effect(() => {
const products = this.products();
console.log(`Products updated: ${products.length} items`);
this.analyticsService.track('products-viewed', { count: products.length });
});
}
}---
Incorrect (Mutating signal values):
// ❌ Mutating the array directly
@Component({...})
export class TodoComponent {
todos = signal<Todo[]>([]);
sortedTodos = computed(() => {
const items = this.todos();
items.sort((a, b) => a.priority - b.priority); // Mutates original!
return items;
});
}Correct (Immutable operations):
// ✅ Create new array, don't mutate
@Component({...})
export class TodoComponent {
todos = signal<Todo[]>([]);
sortedTodos = computed(() =>
[...this.todos()].sort((a, b) => a.priority - b.priority)
// Or use toSorted() in modern JS
// this.todos().toSorted((a, b) => a.priority - b.priority)
);
// ✅ Using spread for objects
todosWithStatus = computed(() =>
this.todos().map(todo => ({
...todo,
statusLabel: todo.done ? 'Complete' : 'Pending'
}))
);
}---
Incorrect (Expensive operations without memoization):
// ❌ Heavy computation runs on every access
@Component({...})
export class DataGridComponent {
data = signal<Row[]>([]);
filter = signal('');
// Problem: Complex filtering runs every time filteredData() is accessed
filteredData = computed(() => {
return this.data()
.filter(row => this.matchesFilter(row, this.filter()))
.sort((a, b) => this.complexSort(a, b))
.map(row => this.transformRow(row));
});
}Correct (Break into smaller computed signals):
// ✅ Chain computed signals - each only recomputes when its deps change
@Component({...})
export class DataGridComponent {
data = signal<Row[]>([]);
filter = signal('');
sortField = signal<keyof Row>('name');
// Each step is memoized independently
filteredData = computed(() =>
this.data().filter(row =>
row.name.toLowerCase().includes(this.filter().toLowerCase())
)
);
sortedData = computed(() =>
[...this.filteredData()].sort((a, b) =>
String(a[this.sortField()]).localeCompare(String(b[this.sortField()]))
)
);
// Only re-transforms when sortedData changes
displayData = computed(() =>
this.sortedData().map(row => ({
...row,
displayName: `${row.firstName} ${row.lastName}`
}))
);
}---
Correct patterns for computed():
@Component({...})
export class ExamplesComponent {
user = signal<User | null>(null);
items = signal<Item[]>([]);
searchTerm = signal('');
// ✅ Derive boolean state
isLoggedIn = computed(() => this.user() !== null);
// ✅ Derive formatted values
displayName = computed(() => {
const user = this.user();
return user ? `${user.firstName} ${user.lastName}` : 'Guest';
});
// ✅ Filter/map operations
visibleItems = computed(() =>
this.items().filter(item =>
item.name.toLowerCase().includes(this.searchTerm().toLowerCase())
)
);
// ✅ Aggregate calculations
stats = computed(() => ({
total: this.items().length,
visible: this.visibleItems().length,
avgPrice: this.items().reduce((s, i) => s + i.price, 0) / this.items().length || 0
}));
// ✅ Combine multiple signals
viewModel = computed(() => ({
user: this.user(),
items: this.visibleItems(),
stats: this.stats(),
isLoggedIn: this.isLoggedIn()
}));
}Why it matters:
- Angular may execute computed() multiple times for optimization
- Side effects (logging, analytics, mutations) will run unpredictably
- computed() is lazy and memoized - only re-runs when dependencies change
- Keeping computed pure makes behavior predictable and testable
Reference: Angular Signals Guide
Use effect() Correctly - Avoid Anti-Patterns
effect() should be your last resort for handling side effects. Misuse leads to infinite loops, memory leaks, and hard-to-debug behavior. Prefer computed() for derived values and direct signal updates for state changes.
Incorrect (Writing to a signal you're reading):
// ❌ INFINITE LOOP - effect reads count, then writes to count
@Component({...})
export class CounterComponent {
count = signal(0);
constructor() {
effect(() => {
// Reads count, triggers effect
// Writes count, triggers effect again
// Infinite loop!
this.count.set(this.count() + 1);
});
}
}Correct (Use computed or direct update):
// ✅ Use computed for derived values
@Component({...})
export class CounterComponent {
count = signal(0);
doubledCount = computed(() => this.count() * 2); // No effect needed!
increment() {
this.count.update(c => c + 1);
}
}---
Incorrect (Nested effects):
// ❌ Memory leak - inner effect never destroyed
@Component({...})
export class BadComponent {
user = signal<User | null>(null);
constructor() {
effect(() => {
const user = this.user();
if (user) {
// Creates new effect on EVERY user change
// Old effects never cleaned up!
effect(() => {
console.log('Nested effect:', user.name);
});
}
});
}
}Correct (Single effect with conditional logic):
// ✅ One effect, conditional inside
@Component({...})
export class GoodComponent {
user = signal<User | null>(null);
constructor() {
effect(() => {
const user = this.user();
if (user) {
console.log('User logged in:', user.name);
this.analyticsService.identify(user.id);
} else {
console.log('User logged out');
this.analyticsService.reset();
}
});
}
}---
Incorrect (Using effect to sync state):
// ❌ Anti-pattern: effect to derive state
@Component({...})
export class BadSyncComponent {
firstName = signal('');
lastName = signal('');
fullName = signal(''); // Should be computed!
constructor() {
// DON'T: Use effect to sync signals
effect(() => {
this.fullName.set(`${this.firstName()} ${this.lastName()}`);
}, { allowSignalWrites: true });
}
}Correct (Use computed for derived state):
// ✅ computed is the right tool for derived values
@Component({...})
export class GoodSyncComponent {
firstName = signal('');
lastName = signal('');
fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
}---
Incorrect (Effect for parent-to-child communication):
// ❌ Anti-pattern: calling child methods from parent effect
@Component({
template: `<app-child #child />`
})
export class BadParentComponent {
data = signal<Data | null>(null);
@ViewChild('child') child!: ChildComponent;
constructor() {
effect(() => {
const data = this.data();
if (data) {
this.child.updateData(data); // Imperative, fragile
}
});
}
}Correct (Use input binding):
// ✅ Declarative data flow with inputs
@Component({
template: `<app-child [data]="data()" />`
})
export class GoodParentComponent {
data = signal<Data | null>(null);
}
// Child receives data reactively via input
@Component({...})
export class ChildComponent {
data = input<Data | null>(null);
processedData = computed(() => {
const d = this.data();
return d ? this.process(d) : null;
});
}---
Correct use cases for effect():
@Component({...})
export class CorrectEffectUsageComponent {
theme = signal<'light' | 'dark'>('light');
searchQuery = signal('');
user = signal<User | null>(null);
constructor() {
// ✅ Sync with external API (DOM, localStorage, etc.)
effect(() => {
document.body.classList.toggle('dark-mode', this.theme() === 'dark');
});
// ✅ Persist to localStorage
effect(() => {
localStorage.setItem('theme', this.theme());
});
// ✅ Logging/Analytics (non-reactive world)
effect(() => {
const user = this.user();
if (user) {
this.analytics.identify(user.id);
}
});
// ✅ Trigger imperative APIs
effect(() => {
const query = this.searchQuery();
if (query.length > 0) {
this.autocomplete.updateSuggestions(query);
}
});
}
}---
Using untracked() to prevent re-runs:
@Component({...})
export class SmartEffectComponent {
query = signal('');
page = signal(1);
constructor() {
// Only re-run when query changes, not when page changes
effect(() => {
const q = this.query(); // Tracked - triggers effect
const p = untracked(() => this.page()); // Untracked - doesn't trigger
this.logSearch(q, p);
});
}
}Effect cleanup:
@Component({...})
export class CleanupEffectComponent {
elementId = signal('my-element');
constructor() {
effect((onCleanup) => {
const id = this.elementId();
const handler = () => console.log('clicked');
document.getElementById(id)?.addEventListener('click', handler);
// Cleanup runs before next effect execution and on destroy
onCleanup(() => {
document.getElementById(id)?.removeEventListener('click', handler);
});
});
}
}Why it matters:
allowSignalWrites: trueis a code smell - usually means you should use computed()- Effects are for syncing with non-reactive external systems
- Nested effects = guaranteed memory leaks
- Circular signal writes = infinite loops
Reference: Angular Effect Guide
Use linkedSignal for Derived + Writable State
linkedSignal() creates a signal that derives its value from other signals (like computed) but can also be manually written to (unlike computed). Perfect for state that should reset when dependencies change but allow user overrides.
The Problem: computed() is read-only:
// ❌ computed is read-only - can't handle user selection
@Component({...})
export class ProductSelectorComponent {
products = signal<Product[]>([]);
// Derived from products, but what if user wants to select different one?
selectedProduct = computed(() => this.products()[0]);
selectProduct(product: Product) {
// ERROR: Cannot set computed signal!
this.selectedProduct.set(product);
}
}Workaround with plain signal has issues:
// ❌ Plain signal doesn't auto-reset when products change
@Component({...})
export class ProductSelectorComponent {
products = signal<Product[]>([]);
selectedProduct = signal<Product | null>(null);
constructor() {
// Must manually sync - error prone
effect(() => {
const products = this.products();
if (products.length > 0) {
this.selectedProduct.set(products[0]);
}
}, { allowSignalWrites: true }); // Code smell!
}
}Correct (linkedSignal):
// ✅ linkedSignal: derives from source + allows manual override
@Component({...})
export class ProductSelectorComponent {
products = signal<Product[]>([]);
// Auto-selects first product, resets when products change
// But can also be manually set by user
selectedProduct = linkedSignal(() => this.products()[0] ?? null);
selectProduct(product: Product) {
this.selectedProduct.set(product); // Works!
}
loadProducts(products: Product[]) {
this.products.set(products);
// selectedProduct automatically resets to first item
}
}---
Use Case: Form with resettable default:
// ✅ Quantity selector that resets when product changes
@Component({
template: `
<select [(ngModel)]="selectedProduct">
@for (p of products(); track p.id) {
<option [ngValue]="p">{{ p.name }}</option>
}
</select>
<input
type="number"
[ngModel]="quantity()"
(ngModelChange)="quantity.set($event)"
[max]="selectedProduct()?.maxQuantity ?? 10"
/>
`
})
export class OrderFormComponent {
products = signal<Product[]>([]);
// Resets to first product when products change
selectedProduct = linkedSignal(() => this.products()[0] ?? null);
// Resets to 1 when selected product changes
quantity = linkedSignal(() => 1);
// Or reset to product's default quantity
quantityWithDefault = linkedSignal(() =>
this.selectedProduct()?.defaultQuantity ?? 1
);
}---
Use Case: Preserve selection if still valid:
// ✅ Advanced: Keep selection if it's still in the new list
@Component({...})
export class SmartSelectorComponent {
options = signal<Option[]>([]);
selectedOption = linkedSignal({
source: this.options,
computation: (options, previous) => {
// If previous selection is still valid, keep it
if (previous && options.some(o => o.id === previous.id)) {
return previous;
}
// Otherwise, select first option
return options[0] ?? null;
}
});
}---
computed vs linkedSignal decision tree:
@Component({...})
export class ComparisonComponent {
items = signal<Item[]>([]);
filter = signal('');
// ✅ computed: Pure derivation, no user interaction needed
filteredItems = computed(() =>
this.items().filter(i => i.name.includes(this.filter()))
);
// ✅ computed: Aggregate value, read-only
totalCount = computed(() => this.items().length);
// ✅ linkedSignal: Default from source, but user can override
selectedItem = linkedSignal(() => this.filteredItems()[0] ?? null);
// ✅ linkedSignal: Pagination that resets on filter change
currentPage = linkedSignal(() => 1); // Resets to 1 when deps accessed inside change
}---
When to use each:
| Scenario | Use |
|---|---|
| Derive read-only value | computed() |
| Derive value + allow user override | linkedSignal() |
| Reset state when dependency changes | linkedSignal() |
| Independent state, no derivation | signal() |
| Access previous value during computation | linkedSignal({ computation }) |
---
Incorrect (effect with allowSignalWrites):
// ❌ Anti-pattern: effect to sync derived state
@Component({...})
export class BadComponent {
source = signal<string[]>([]);
selected = signal<string | null>(null);
constructor() {
effect(() => {
const items = this.source();
this.selected.set(items[0] ?? null);
}, { allowSignalWrites: true }); // Avoid this!
}
}Correct (linkedSignal):
// ✅ linkedSignal is designed for this exact use case
@Component({...})
export class GoodComponent {
source = signal<string[]>([]);
selected = linkedSignal(() => this.source()[0] ?? null);
}Why it matters:
- linkedSignal expresses intent: "derived with override capability"
- Avoids
allowSignalWritescode smell in effects - Automatic reset behavior is declarative and predictable
- Previous value access enables smart selection preservation
Reference: Angular linkedSignal
Use Incremental Hydration for SSR
Incremental hydration defers JavaScript loading for below-fold components, reducing initial bundle size and improving Time to Interactive.
Incorrect (Full hydration of all components):
@Component({
template: `
<app-header />
<app-hero />
<app-comments [postId]="postId" /> <!-- Heavy, below fold -->
<app-recommendations /> <!-- Heavy, below fold -->
<app-footer />
`
})
export class PostComponent {
postId = input.required<string>();
}Correct (Incremental hydration with @defer):
@Component({
template: `
<app-header />
<app-hero />
@defer (hydrate on viewport) {
<app-comments [postId]="postId()" />
} @placeholder {
<div class="comments-skeleton">Loading comments...</div>
}
@defer (hydrate on idle) {
<app-recommendations />
}
@defer (hydrate never) {
<app-footer />
}
`
})
export class PostComponent {
postId = input.required<string>();
}Why it matters:
hydrate on viewport- Hydrates when scrolled into viewhydrate on idle- Hydrates during browser idle timehydrate on interaction- Hydrates on user click/focushydrate never- Never hydrates (static content only)
Reference: Angular SSR
Use NgOptimizedImage for Images
NgOptimizedImage enforces image best practices: automatic lazy loading, priority hints for LCP images, and prevents layout shift.
Incorrect (Native img without optimization):
<!-- No lazy loading, no priority hints, potential CLS -->
<img src="/assets/hero.jpg" alt="Hero image">
<!-- May cause layout shift without dimensions -->
<img src="{{ user.avatar }}" alt="User avatar">Correct (NgOptimizedImage with best practices):
@Component({
imports: [NgOptimizedImage],
template: `
<!-- Priority image (above fold, LCP candidate) -->
<img
ngSrc="/assets/hero.jpg"
alt="Hero image"
width="1200"
height="600"
priority
/>
<!-- Lazy loaded by default (below fold) -->
<img
[ngSrc]="user().avatar"
alt="User avatar"
width="64"
height="64"
/>
`
})
export class ProductComponent {
user = input.required<User>();
}Why it matters:
priorityattribute for above-fold images (LCP)- Automatic lazy loading for below-fold images
- Required
width/heightprevents layout shift fillmode available for dynamic containers
Reference: Angular Image Optimization
Use Pure Pipes for Data Transformation
Pure pipes are only executed when inputs change by reference. They're memoized, unlike template methods which run on every change detection cycle.
Incorrect (Method called on every change detection):
@Component({
template: `
@for (product of products; track product.id) {
<!-- formatPrice called on EVERY change detection cycle -->
<span>{{ formatPrice(product.price) }}</span>
}
`
})
export class ProductListComponent {
formatPrice(price: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(price);
}
}Correct (Pure pipe only runs when input changes):
@Pipe({ name: 'price' })
export class PricePipe implements PipeTransform {
transform(value: number, currency = 'USD'): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency
}).format(value);
}
}
@Component({
imports: [PricePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (product of products; track product.id) {
<span>{{ product.price | price }}</span>
}
`
})
export class ProductListComponent {
products = signal<Product[]>([]);
}Why it matters:
- Pure pipes are memoized by Angular
- Only recalculate when input reference changes
- Methods in templates run on every change detection
- Significant performance gain in loops
Reference: Angular Pipes
Use @for with track for Loops
@for requires a track expression, enforcing efficient DOM reuse. Without tracking, Angular recreates all DOM elements when the array changes.
Incorrect (No tracking causes full DOM recreation):
@Component({
template: `
<!-- All items re-render when array changes -->
<div *ngFor="let user of users">
<app-user-card [user]="user" />
</div>
`
})
export class UserListComponent {
users: User[] = [];
}Correct (@for with required track):
@Component({
template: `
@for (user of users(); track user.id) {
<app-user-card [user]="user" />
} @empty {
<p>No users found</p>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserListComponent {
users = signal<User[]>([]);
}Why it matters:
track user.ididentifies items for DOM reuse- Only changed items are re-rendered, not the entire list
@emptyblock handles empty array case- Required
trackprevents accidental performance issues
Reference: Angular Control Flow