Skip to main content

Angular Best Practices

Why Angular?

Angular's opinionated architecture, strong TypeScript integration, and enterprise-ready features make it ideal for complex enterprise applications. Built-in dependency injection, RxJS for reactive programming, and comprehensive testing tools ensure maintainable, scalable applications. Angular 20+ with signals and standalone components modernizes the framework while maintaining backward compatibility.

Overview

This guide covers Angular best practices using Angular 20+ with standalone components, signals, and modern patterns. The focus is on TypeScript strict mode, reactive programming with RxJS, and performance optimization through strategic use of change detection strategies and lazy loading.


Core Principles

  1. Standalone Components: Prefer standalone over NgModules
  2. Signals for Reactivity: Use signals for synchronous state; RxJS for async
  3. Dependency Injection: Leverage Angular's DI system
  4. OnPush Change Detection: Optimize performance early
  5. Reactive Forms: Type-safe, testable form handling
  6. TypeScript Strict Mode: Enable all strict compiler options

Project Structure

Angular projects benefit from a well-organized directory structure that separates concerns and improves maintainability. The structure below follows the principle of organizing by feature rather than by type, which scales better as applications grow.

src/
|-- app/
| |-- core/ # Singleton services, guards, interceptors
| | |-- guards/ # Route guards (auth, role-based access)
| | |-- interceptors/ # HTTP interceptors (auth tokens, logging)
| | `-- services/ # App-wide services (single instance)
| |-- shared/ # Reusable components, pipes, directives
| | |-- components/ # UI components used across features
| | |-- directives/ # Custom directives (attribute/structural)
| | `-- pipes/ # Custom pipes for data transformation
| |-- features/ # Feature areas
| | `-- payments/
| | |-- components/ # Feature-specific components
| | |-- services/ # Feature-scoped services
| | `-- models/ # TypeScript interfaces/types
| |-- app.component.ts # Root component
| |-- app.config.ts # App-level providers (DI configuration)
| `-- app.routes.ts # Route configuration
|-- assets/ # Static files (images, fonts)
|-- environments/ # Environment-specific config
`-- main.ts # Application entry point

Key principles:

  • Core: Services that should have a single instance across the app (e.g., authentication, logging)
  • Shared: Components and utilities used by multiple features
  • Features: Self-contained feature directories that can be lazy loaded to reduce initial bundle size
  • Standalone components: Eliminate the need for NgModules in most cases, simplifying imports

Standalone Components

Standalone components, introduced in Angular 14 and the default in modern Angular, eliminate the need for NgModules in most cases. Each component explicitly declares its dependencies through the imports array, making dependencies clear and improving tree-shaking. This results in smaller bundles and simpler architecture.

The key difference from module-based components is that standalone components manage their own dependencies rather than relying on a parent module. This makes components more portable and easier to test in isolation.

Basic Standalone Component

// payment-card.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CurrencyPipe } from '@angular/common';

interface Payment {
id: string;
amount: number;
currency: string;
status: 'pending' | 'completed' | 'failed';
}

@Component({
selector: 'app-payment-card',
standalone: true,
imports: [CurrencyPipe], // Import individual pipes directly — no CommonModule needed
template: `
<div class="payment-card">
<div class="payment-card__header">
<span>{{ payment.id }}</span>
<span [class]="'status-' + payment.status">
{{ payment.status }}
</span>
</div>
<div class="payment-card__amount">
{{ payment.amount | currency: payment.currency }}
</div>
<button (click)="onView()">View Details</button>
</div>
`,
styleUrls: ['./payment-card.component.scss']
})
export class PaymentCardComponent {
@Input({ required: true }) payment!: Payment;
@Output() view = new EventEmitter<string>();

onView(): void {
this.view.emit(this.payment.id);
}
}

Key aspects of this component:

  • standalone: true: Marks the component as standalone, removing the need for NgModule
  • imports: [CurrencyPipe]: Import specific pipes directly. CommonModule is only needed if you're using the old *ngIf/*ngFor structural directives — with modern @if/@for control flow, you don't need it
  • @Input({ required: true }): Enforces that the payment input must be provided by parent components, preventing runtime errors
  • @Output() view: Event emitter for child-to-parent communication following Angular's unidirectional data flow
  • Template: Inline template using Angular's template syntax with interpolation ({{ }}) and event binding ((click))

Bootstrapping with Standalone

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
import { authInterceptor } from './app/core/interceptors/auth.interceptor';

bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor]))
]
});

In standalone applications, bootstrapApplication replaces the traditional platformBrowserDynamic().bootstrapModule() pattern. Providers are configured functionally using provideRouter, provideHttpClient, etc., which is more tree-shakeable than the old module-based approach. The withInterceptors function adds HTTP interceptors in a functional style, replacing the old HTTP_INTERCEPTORS token approach.


Signals (Angular 16+)

Signals are Angular's new primitive for reactive state management, introduced in Angular 16 as a more performant and simpler alternative to observables for synchronous state. Unlike observables, signals are pull-based rather than push-based, meaning they only recompute when accessed. This makes them ideal for component state and computed values that change in response to user interactions.

Signals integrate directly with Angular's change detection system. When a signal changes, Angular knows exactly which components need to update, avoiding unnecessary change detection cycles. This is particularly powerful when combined with OnPush change detection (see Angular Performance for details on optimization strategies).

Basic Signals

import { Component, signal, computed } from '@angular/core';

@Component({
selector: 'app-payment-dashboard',
standalone: true,
template: `
<div>
<p>Total: {{ totalAmount() }}</p>
<p>Count: {{ paymentCount() }}</p>
<button (click)="addPayment()">Add Payment</button>
</div>
`
})
export class PaymentDashboardComponent {
// Writable signal
payments = signal<Payment[]>([]);

// Computed signal (derived state)
totalAmount = computed(() =>
this.payments().reduce((sum, p) => sum + p.amount, 0)
);

paymentCount = computed(() => this.payments().length);

addPayment(): void {
this.payments.update(current => [
...current,
{ id: 'PAY-' + Date.now(), amount: 100, currency: 'USD', status: 'pending' }
]);
}
}

Signal operations:

  • signal<T>(initialValue): Creates a writable signal with an initial value
  • computed(() => ...): Creates a derived signal that automatically recomputes when dependencies change
  • .update(fn): Updates signal value based on current value, useful for immutable updates
  • .set(value): Directly sets signal value
  • Signal reads (): Signals are called as functions to read their current value, which automatically tracks dependencies

In the template, signals are called directly (totalAmount(), paymentCount()). Angular's compiler recognizes these calls and subscribes to changes automatically, updating the DOM only when these specific signals change.

Signals vs RxJS

//  GOOD: Use signals for synchronous state
class PaymentFormComponent {
amount = signal(0);
currency = signal('USD');

// Computed values
isValid = computed(() => this.amount() > 0);
}

// GOOD: Use RxJS for async operations
class PaymentService {
private http = inject(HttpClient);

getPayments(): Observable<Payment[]> {
return this.http.get<Payment[]>('/api/payments');
}

// Convert Observable to Signal if needed
payments$ = this.getPayments();
paymentsSignal = toSignal(this.payments$, { initialValue: [] });
}

When to use each:

  • Signals: Synchronous, local state (form values, UI toggles, computed values). Signals have lower overhead and integrate more naturally with templates.
  • RxJS: Asynchronous operations (HTTP requests, WebSockets, timers), complex data transformations (debouncing, throttling, combining multiple streams). RxJS provides powerful operators for async data flows.
  • Conversion: Use toSignal() to convert observables to signals for easier template binding, and toObservable() to convert signals to observables when you need RxJS operators.

Quick decision rule:

  • If the source is user interaction or local UI state, start with signals.
  • If the source is time/network/event stream, start with RxJS.
  • Convert at boundaries; do not force one abstraction everywhere.

For more details on combining these patterns, see Angular State Management.


Dependency Injection

Angular's dependency injection (DI) system enables loose coupling and testability through the hierarchical injector pattern. For general dependency injection concepts and SOLID principles, see Architecture Overview. The modern inject() function, introduced in Angular 14, provides a simpler and more flexible alternative to constructor injection - it can be used anywhere within the injection context (constructors, class fields, factory functions).

Angular resolves dependencies by walking up the injector tree until it finds a matching provider. The providedIn: 'root' pattern creates singletons that are tree-shakeable - if never injected, they're not included in the bundle.

Modern inject() Function

import { Component, inject } from '@angular/core';
import { PaymentService } from './services/payment.service';
import { Router } from '@angular/router';

@Component({
selector: 'app-payment-list',
standalone: true,
template: `...`
})
export class PaymentListComponent {
// GOOD: Use inject() function
private paymentService = inject(PaymentService);
private router = inject(Router);

payments$ = this.paymentService.getPayments();

viewPayment(id: string): void {
this.router.navigate(['/payments', id]);
}
}

// Also valid: constructor injection
export class PaymentSummaryComponent {
constructor(
private paymentService: PaymentService,
private router: Router
) {}
}

Both inject() and constructor injection are valid in modern Angular:

  • Use inject() when you want field-level clarity, factory-style composition, or injection outside constructors.
  • Use constructor injection when dependencies are required for object construction and you want explicit constructor contracts.

Avoid mixing styles randomly in the same file. Pick one style per class for consistency.

Providing Services

// Service with root-level DI
import { Injectable } from '@angular/core';

@Injectable({
providedIn: 'root' // Singleton across app
})
export class PaymentService {
// Service logic
}

// Component-level provider
@Component({
selector: 'app-payment-form',
standalone: true,
providers: [PaymentFormService] // New instance per component
})
export class PaymentFormComponent {}

RxJS Patterns

RxJS (Reactive Extensions for JavaScript) is deeply integrated into Angular for handling asynchronous operations. The library provides a powerful set of operators for transforming, combining, and managing streams of data. Angular uses observables throughout its API - HTTP requests return observables, form value changes are observables, and route parameters are observables. Mastering operators like map, switchMap, catchError, and combineLatest enables you to compose complex async logic declaratively.

The async pipe is the cornerstone of reactive Angular applications. It automatically subscribes to observables, unwraps their values for template use, and - critically - automatically unsubscribes when the component is destroyed, preventing memory leaks.

Async Pipe

@Component({
selector: 'app-payment-list',
standalone: true,
imports: [AsyncPipe],
template: `
@if (payments$ | async; as payments) {
@for (payment of payments; track payment.id) {
<app-payment-card [payment]="payment" />
}
} @else {
<div>Loading...</div>
}
`
})
export class PaymentListComponent {
private paymentService = inject(PaymentService);

// GOOD: Use async pipe (no manual subscription)
payments$ = this.paymentService.getPayments();
}

Benefits of async pipe:

  • Automatically subscribes when component renders
  • Automatically unsubscribes when component destroys (prevents memory leaks)
  • Triggers change detection when new values arrive
  • Works with OnPush change detection strategy
  • Reduces boilerplate compared to manual subscription management

The track expression in @for is crucial for performance in lists (see Angular Performance). It tells Angular how to identify list items so it can reuse DOM elements instead of recreating them when the array changes. Unlike the old trackBy function, track is an inline expression directly in the loop.

Avoiding Nested Subscriptions

//  BAD: Nested subscriptions
this.route.params.subscribe(params => {
this.paymentService.getPayment(params['id']).subscribe(payment => {
this.payment = payment;
});
});

// GOOD: Use switchMap
this.payment$ = this.route.params.pipe(
switchMap(params => this.paymentService.getPayment(params['id']))
);

Nested subscriptions are a common anti-pattern that leads to callback hell, difficulty managing subscriptions, and potential memory leaks. The switchMap operator flattens the nested observables into a single stream. It automatically cancels previous inner subscriptions when a new outer value arrives, which is ideal for scenarios like route parameter changes - you don't want to fetch data for a previous ID if the user has already navigated to a new one.

Other flattening operators include:

  • concatMap: Queues requests, waits for each to complete (order matters)
  • mergeMap: Runs requests concurrently (when order doesn't matter)
  • exhaustMap: Ignores new requests while one is active (prevent duplicate submissions)

Error Handling

import { catchError, of } from 'rxjs';

payments$ = this.paymentService.getPayments().pipe(
catchError(error => {
console.error('Failed to load payments:', error);
return of([]); // Return empty array on error
})
);

The catchError operator catches errors in the observable stream and allows you to return a fallback value, preventing the stream from terminating. Without error handling, a single failed HTTP request would break the entire observable chain, requiring component reinitialization. By returning of([]), we provide a graceful fallback that keeps the UI functional even when the API fails.

For more robust error handling strategies, consider:

  • Showing user-friendly error messages via a notification service
  • Retrying failed requests with exponential backoff using the retry operator
  • Logging errors to a monitoring service for diagnostics

Forms

Angular provides two approaches to forms: Template-driven (similar to AngularJS) and Reactive (preferred). Reactive forms provide better type safety, easier testing, and more explicit data flow. Angular 14+ introduces typed forms that leverage TypeScript's type system to catch errors at compile time rather than runtime.

Reactive forms separate the form model from the view, making it easier to test form logic without rendering components. The form model is defined in the component class, and validation rules are declared programmatically rather than in templates.

Reactive Forms with Typed Forms

import { Component } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { CommonModule } from '@angular/common';

interface PaymentForm {
amount: number;
currency: string;
vendorId: string;
}

@Component({
selector: 'app-payment-form',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div>
<label for="amount">Amount</label>
<input id="amount" type="number" formControlName="amount" />
@if (form.get('amount')?.invalid && form.get('amount')?.touched) {
<div>Amount is required and must be positive</div>
}
</div>

<div>
<label for="currency">Currency</label>
<select id="currency" formControlName="currency">
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</select>
</div>

<div>
<label for="vendorId">Vendor ID</label>
<input id="vendorId" formControlName="vendorId" />
</div>

<button type="submit" [disabled]="form.invalid">Submit</button>
</form>
`
})
export class PaymentFormComponent {
private fb = inject(FormBuilder);

form = this.fb.nonNullable.group({
amount: [0, [Validators.required, Validators.min(0.01)]],
currency: ['USD', Validators.required],
vendorId: ['', Validators.required]
});

onSubmit(): void {
if (this.form.valid) {
const value = this.form.getRawValue();
console.log('Form value:', value);
}
}
}

Change Detection

Angular's change detection system is responsible for synchronizing the component state with the view. By default, Angular uses the Default strategy, which checks every component in the tree whenever any event occurs (clicks, timers, HTTP responses). This can become expensive in large applications with thousands of bindings.

The OnPush change detection strategy is an opt-in optimization that tells Angular to only check a component when:

  1. Input properties change (by reference, not deep equality)
  2. Events originate from the component or its children
  3. An observable bound with async pipe emits a new value
  4. Change detection is manually triggered

This dramatically reduces the number of checks Angular performs, especially in lists with many items. OnPush components must follow immutability patterns - updating objects in place won't trigger change detection because Angular compares references, not deep values.

OnPush Strategy

import { Component, ChangeDetectionStrategy } from '@angular/core';

@Component({
selector: 'app-payment-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, // Only check when inputs change
template: `...`
})
export class PaymentCardComponent {
@Input({ required: true }) payment!: Payment;
}
OnPush Benefits
  • Reduces change detection cycles
  • Improves performance for large lists
  • Makes component behavior predictable
  • Prepares for future signals migration

Routing

Angular's router enables navigation between views and supports advanced features like lazy loading, route guards, and resolvers. With standalone components, routing becomes simpler using loadComponent and functional guards.

Lazy loading is a critical performance optimization that loads feature code on demand rather than upfront. This reduces the initial bundle size, improving first-page load times. The Angular router uses dynamic imports (import()) under the hood, which webpack automatically code-splits into separate chunks.

Route Configuration

// app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
{
path: '',
redirectTo: '/payments',
pathMatch: 'full'
},
{
path: 'payments',
loadComponent: () =>
import('./features/payments/payment-list.component').then(
m => m.PaymentListComponent
)
},
{
path: 'payments/:id',
loadComponent: () =>
import('./features/payments/payment-details.component').then(
m => m.PaymentDetailsComponent
),
canActivate: [authGuard]
}
];

Route configuration notes:

  • redirectTo: Redirects from empty path to default route
  • pathMatch: 'full': Ensures exact match for redirect (prevents unwanted redirects on sub-routes)
  • loadComponent: Dynamically imports a single component (code splitting)
  • canActivate: Protects routes with guards (authentication, authorization, etc.)

Lazy loaded routes are bundled separately and fetched only when the user navigates to them. This keeps the initial bundle small and reduces time-to-interactive.

Guards (Functional)

// auth.guard.ts
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { AuthService } from './services/auth.service';

export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);

if (authService.isAuthenticated()) {
return true;
}

return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }
});
};

Functional guards replace class-based guards (which implemented CanActivate, CanDeactivate, etc. interfaces). They're simpler, easier to test, and can leverage inject() directly. Guards can return:

  • true: Allow navigation
  • false: Block navigation
  • UrlTree: Redirect to another route

The returnUrl query parameter preserves the user's intended destination, allowing redirect back after successful authentication.


HTTP Interceptors

HTTP interceptors provide a centralized way to modify HTTP requests and responses across the entire application. Common use cases include adding authentication tokens, logging, error handling, and request/response transformation. Functional interceptors (Angular 15+) replace class-based interceptors with a simpler function-based API that works seamlessly with standalone components.

Functional Interceptors

// auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const token = authService.getToken();

if (token) {
const cloned = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
return next(cloned);
}

return next(req);
};

The interceptor receives the request and a next function that represents the next interceptor in the chain (or the HTTP backend). By cloning the request and adding headers, we can modify requests without mutating the original (requests are immutable). Multiple interceptors can be chained, executing in the order they're provided in withInterceptors([]).

Interceptors have access to the full request/response lifecycle, making them ideal for cross-cutting concerns. For error handling interceptors, you can pipe the next() result and use RxJS operators like catchError to handle errors globally.


Pipes

Pipes transform data in templates without modifying the underlying values. Angular provides built-in pipes (date, currency, uppercase, etc.) and allows you to create custom pipes for domain-specific transformations. Pipes are declarative and composable - you can chain multiple pipes together (value | pipe1 | pipe2).

By default, pipes are pure, meaning they only execute when their input reference changes. This is efficient but requires immutability patterns. Impure pipes execute on every change detection cycle, which can harm performance if they perform expensive operations.

Custom Pipe

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'paymentStatus',
standalone: true
})
export class PaymentStatusPipe implements PipeTransform {
transform(status: string): string {
const statusMap: Record<string, string> = {
pending: ' Pending',
completed: ' Completed',
failed: ' Failed'
};
return statusMap[status] || status;
}
}

// Usage
// {{ payment.status | paymentStatus }}

Component Patterns

Angular provides specific patterns for component composition and content projection. Understanding these patterns enables building flexible, reusable component APIs.

For universal component architecture principles (container vs presentational pattern, composition strategies), see Web Component Architecture.

Content Projection with ng-content

Content projection is Angular's mechanism for accepting content from parent components, similar to React's children prop or Vue's slots. The <ng-content> element marks where projected content should be rendered.

Basic Content Projection:

// Card.component.ts
import { Component } from '@angular/core';

@Component({
selector: 'app-card',
standalone: true,
template: `
<div class="card">
<ng-content></ng-content>
</div>
`,
styles: [`
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
}
`]
})
export class CardComponent {}

// Usage - project any content
<app-card>
<h2>Payment Details</h2>
<p>Amount: $100.00</p>
</app-card>

The content between <app-card> tags is projected into the <ng-content> slot. The Card component controls layout and styling, while the parent provides the content.

Multi-Slot Content Projection

Named slots enable multiple projection areas, creating flexible component APIs. Use the select attribute to target specific content.

// Modal.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
selector: 'app-modal',
standalone: true,
imports: [],
template: `
@if (isOpen) {
<div class="modal-backdrop" (click)="onClose.emit()">
<div class="modal" (click)="$event.stopPropagation()">
<div class="modal__header">
<ng-content select="[modal-header]"></ng-content>
<button (click)="onClose.emit()" class="modal__close">&times;</button>
</div>

<div class="modal__body">
<ng-content select="[modal-body]"></ng-content>
</div>

@if (hasFooter) {
<div class="modal__footer">
<ng-content select="[modal-footer]"></ng-content>
</div>
}
</div>
</div>
}
`,
styleUrls: ['./modal.component.scss']
})
export class ModalComponent {
@Input() isOpen = false;
@Output() onClose = new EventEmitter<void>();

// Check if footer content was projected
hasFooter = false;

ngAfterContentInit() {
// Could check for projected content here if needed
}
}

// Usage - project content to specific slots
<app-modal [isOpen]="showModal" (onClose)="handleClose()">
<div modal-header>
<h2>Confirm Payment</h2>
</div>

<div modal-body>
<p>Are you sure you want to send $100.00?</p>
<app-payment-details [payment]="payment"></app-payment-details>
</div>

<div modal-footer>
<button (click)="handleClose()">Cancel</button>
<button (click)="handleConfirm()">Confirm</button>
</div>
</app-modal>

How selection works:

  • select="[modal-header]": Projects elements with the modal-header attribute
  • select=".class-name": Projects elements with the CSS class
  • select="element-name": Projects specific element types
  • No select: Projects all content not matching other selectors

This pattern creates component APIs that are flexible (consumers arrange content as needed) while maintaining consistent structure (component controls layout).

ng-template for Advanced Projection

ng-template enables projecting template fragments with context, useful for customizable rendering logic.

// DataTable.component.ts
import { Component, Input, TemplateRef, ContentChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';

@Component({
selector: 'app-data-table',
standalone: true,
imports: [NgTemplateOutlet],
template: `
<table class="data-table">
<thead>
<tr>
@for (column of columns; track column) {
<th>{{ column }}</th>
}
</tr>
</thead>
<tbody>
@for (row of data; track row) {
<tr>
<!-- Use custom row template if provided -->
@if (rowTemplate) {
<ng-container *ngTemplateOutlet="rowTemplate; context: { $implicit: row }">
</ng-container>
} @else {
@for (column of columns; track column) {
<td>{{ row[column] }}</td>
}
}
</tr>
}
</tbody>
</table>
`
})
export class DataTableComponent<T> {
@Input() data: T[] = [];
@Input() columns: string[] = [];

// Accept custom template for row rendering
@ContentChild('rowTemplate') rowTemplate?: TemplateRef<any>;
}

// Usage - provide custom row template
<app-data-table [data]="payments" [columns]="['id', 'amount', 'status']">
<ng-template #rowTemplate let-payment>
<td>{{ payment.id }}</td>
<td>{{ payment.amount | currency }}</td>
<td>
<span [class]="'status-' + payment.status">
{{ payment.status | uppercase }}
</span>
</td>
</ng-template>
</app-data-table>

Template context: The context: { $implicit: row } provides data to the template. The let-payment syntax in the template receives this data.

This pattern is particularly powerful for tables, lists, and other components where consumers need control over item rendering while the component manages the overall structure and iteration.

ng-container for Structural Composition

ng-container is a logical grouping element that doesn't render to the DOM. It's useful for applying structural directives without adding extra wrapper elements.

@Component({
selector: 'app-payment-list',
standalone: true,
imports: [CurrencyPipe],
template: `
<div class="payment-list">
@if (payments.length > 0) {
@for (payment of payments; track payment.id) {
<div class="payment-item">
<span>{{ payment.id }}</span>
<span>{{ payment.amount | currency }}</span>
</div>
}
} @else {
<div class="empty-state">No payments found.</div>
}
</div>
`
})
export class PaymentListComponent {
@Input() payments: Payment[] = [];
}

Without ng-container, you'd need an extra <div> which might break your CSS layout or add unnecessary DOM nodes.

Component Communication Patterns

Angular components communicate through several mechanisms:

1. Input/Output (Props Down, Events Up):

// Parent -> Child: @Input
// Child -> Parent: @Output with EventEmitter

@Component({
selector: 'app-payment-card',
template: `
<div class="card">
<h3>{{ payment.id }}</h3>
<button (click)="view.emit(payment.id)">View</button>
</div>
`
})
export class PaymentCardComponent {
@Input({ required: true }) payment!: Payment;
@Output() view = new EventEmitter<string>();
}

// Usage
<app-payment-card
[payment]="payment"
(view)="handleView($event)"
></app-payment-card>

2. Services for Shared State:

Services with dependency injection provide shared state across components. See the Dependency Injection section above for implementation details.

3. ViewChild/ContentChild for Direct Component Access:

@Component({
selector: 'app-parent',
template: `
<app-child #childRef></app-child>
<button (click)="callChildMethod()">Trigger Child</button>
`
})
export class ParentComponent {
@ViewChild('childRef') child!: ChildComponent;

callChildMethod() {
this.child.someMethod();
}
}

Use @ViewChild sparingly - it creates tight coupling. Prefer Input/Output for component communication.

For universal component patterns (container vs presentational, composition strategies), see Web Component Architecture.


Testing

See Angular Testing for comprehensive testing patterns.


Further Reading

Angular Framework Guidelines

Cross-Cutting Guidelines

External Resources


Summary

Key Takeaways

  1. Standalone components are the modern Angular pattern; prefer over NgModules
  2. Signals for synchronous state, RxJS for async operations
  3. inject() function for dependency injection in modern Angular
  4. OnPush change detection improves performance significantly
  5. Async pipe prevents memory leaks from manual subscriptions
  6. Reactive forms provide type safety and testability
  7. Functional guards and interceptors replace class-based patterns
  8. Lazy load routes with loadComponent for better performance
  9. trackBy in ngFor optimizes list rendering
  10. TypeScript strict mode catches bugs at compile time

Next Steps: Review Angular State Management for state patterns and Angular Testing for testing strategies.