Skip to content

Angular Best Practices βœ…

Following consistent best practices is the difference between an Angular app that scales gracefully and one that becomes a maintenance nightmare. This guide covers the essential patterns and conventions recommended for Angular 21 applications.

Each component should have a single responsibility. If a component grows beyond ~100 lines of template, it’s time to extract child components.

// βœ… Good β€” focused component
@Component({
selector: 'app-user-avatar',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<img
[src]="avatarUrl()"
[alt]="name() + ' avatar'"
class="avatar"
[class.large]="size() === 'large'"
/>
`,
})
export class UserAvatarComponent {
name = input.required<string>();
size = input<'small' | 'medium' | 'large'>('medium');
private readonly baseUrl = 'https://api.example.com/avatars';
avatarUrl = computed(() => `${this.baseUrl}/${this.name()}.png`);
}

OnPush tells Angular to only check the component when its inputs change or a signal updates. This dramatically improves performance.

@Component({
selector: 'app-product-card',
changeDetection: ChangeDetectionStrategy.OnPush, // always set this
template: `
<div class="card">
<h3>{{ product().name }}</h3>
<p>{{ product().price | currency }}</p>
<button (click)="addToCart.emit(product())">Add to Cart</button>
</div>
`,
})
export class ProductCardComponent {
product = input.required<Product>();
addToCart = output<Product>();
}

Signals provide fine-grained reactivity and work beautifully with OnPush change detection.

@Component({
selector: 'app-counter',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div>
<p>Count: {{ count() }}</p>
<p>Double: {{ doubleCount() }}</p>
<button (click)="increment()">+</button>
<button (click)="decrement()">-</button>
<button (click)="reset()">Reset</button>
</div>
`,
})
export class CounterComponent {
count = signal(0);
doubleCount = computed(() => this.count() * 2);
increment() {
this.count.update(c => c + 1);
}
decrement() {
this.count.update(c => c - 1);
}
reset() {
this.count.set(0);
}
}

The function-based API is type-safe and integrates naturally with signals.

// βœ… Good β€” function-based inputs and outputs
export class TodoItemComponent {
todo = input.required<Todo>();
editable = input(false);
toggled = output<Todo>();
deleted = output<string>();
}
// ❌ Bad β€” decorator-based (legacy)
export class TodoItemComponent {
@Input() todo!: Todo;
@Input() editable = false;
@Output() toggled = new EventEmitter<Todo>();
@Output() deleted = new EventEmitter<string>();
}

This makes the service tree-shakable β€” if no component injects it, it’s removed from the bundle.

@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
private readonly currentUser = signal<User | null>(null);
readonly user = this.currentUser.asReadonly();
readonly isLoggedIn = computed(() => this.currentUser() !== null);
login(credentials: LoginCredentials) {
return this.http.post<User>('/api/auth/login', credentials).pipe(
tap(user => this.currentUser.set(user)),
);
}
logout() {
this.currentUser.set(null);
this.router.navigate(['/login']);
}
}

The inject() function is more concise and works in any injection context.

// βœ… Good β€” inject() function
@Injectable({ providedIn: 'root' })
export class ProductService {
private readonly http = inject(HttpClient);
private readonly config = inject(APP_CONFIG);
}
// ❌ Bad β€” constructor injection (verbose)
@Injectable({ providedIn: 'root' })
export class ProductService {
constructor(
private readonly http: HttpClient,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
}

Prevent external code from modifying internal state by exposing readonly signals.

@Injectable({ providedIn: 'root' })
export class CartService {
private readonly items = signal<CartItem[]>([]);
// Expose readonly versions
readonly cartItems = this.items.asReadonly();
readonly totalPrice = computed(() =>
this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
readonly itemCount = computed(() =>
this.items().reduce((sum, item) => sum + item.quantity, 0)
);
addItem(product: Product) {
this.items.update(items => {
const existing = items.find(i => i.productId === product.id);
if (existing) {
return items.map(i =>
i.productId === product.id
? { ...i, quantity: i.quantity + 1 }
: i
);
}
return [...items, { productId: product.id, price: product.price, quantity: 1 }];
});
}
removeItem(productId: string) {
this.items.update(items => items.filter(i => i.productId !== productId));
}
}

Always use the built-in @if, @for, and @switch blocks instead of structural directives.

<!-- βœ… Good β€” native control flow -->
@if (user()) {
<app-user-profile [user]="user()!" />
} @else {
<app-login-prompt />
}
@for (item of items(); track item.id) {
<app-list-item [item]="item" />
} @empty {
<p>No items found.</p>
}
<!-- ❌ Bad β€” structural directives -->
<app-user-profile *ngIf="user$ | async as user" [user]="user" />
<app-list-item *ngFor="let item of items; trackBy: trackById" [item]="item" />

The track expression is required and enables efficient DOM updates.

<!-- Track by unique identifier β€” best for most cases -->
@for (product of products(); track product.id) {
<app-product-card [product]="product" />
}
<!-- Track by index β€” only when items have no unique ID -->
@for (item of simpleList(); track $index) {
<span>{{ item }}</span>
}

Move calculations to computed() signals or component methods.

// βœ… Good β€” logic in computed signal
filteredProducts = computed(() =>
this.products().filter(p => p.price <= this.maxPrice())
);
// Template is clean:
// @for (product of filteredProducts(); track product.id) { ... }
// ❌ Bad β€” complex logic in template
// @for (product of products().filter(p => p.price <= maxPrice()); track product.id) { ... }

Split your app into lazy-loaded feature routes to reduce the initial bundle size.

export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'products',
loadComponent: () =>
import('./features/products/product-list.component')
.then(m => m.ProductListComponent),
},
{
path: 'admin',
loadChildren: () =>
import('./features/admin/admin.routes')
.then(m => m.adminRoutes),
canActivate: [authGuard],
},
];

Defer blocks let you lazy-load parts of a template based on triggers.

<!-- Load when visible in viewport -->
@defer (on viewport) {
<app-heavy-chart [data]="chartData()" />
} @placeholder {
<div class="chart-skeleton">Loading chart...</div>
} @loading (minimum 300ms) {
<app-spinner />
}
<!-- Load on interaction -->
@defer (on interaction) {
<app-comment-section [postId]="post().id" />
} @placeholder {
<button>Load Comments</button>
}

The NgOptimizedImage directive optimizes image loading with lazy loading, priority hints, and size warnings.

<!-- Priority image (above the fold) -->
<img ngSrc="/assets/hero.jpg" width="1200" height="600" priority />
<!-- Lazy-loaded image (default behavior) -->
<img ngSrc="/assets/product.jpg" width="400" height="300" />

Organize code by feature, not by type. Each feature folder contains its own components, services, and routes.

src/app/
β”œβ”€β”€ core/ # Singleton services, guards, interceptors
β”‚ β”œβ”€β”€ auth.service.ts
β”‚ β”œβ”€β”€ auth.guard.ts
β”‚ └── api.interceptor.ts
β”œβ”€β”€ shared/ # Reusable components, pipes, directives
β”‚ β”œβ”€β”€ components/
β”‚ β”œβ”€β”€ pipes/
β”‚ └── directives/
β”œβ”€β”€ features/
β”‚ β”œβ”€β”€ products/
β”‚ β”‚ β”œβ”€β”€ product-list.component.ts
β”‚ β”‚ β”œβ”€β”€ product-detail.component.ts
β”‚ β”‚ β”œβ”€β”€ product.service.ts
β”‚ β”‚ └── products.routes.ts
β”‚ β”œβ”€β”€ cart/
β”‚ β”‚ β”œβ”€β”€ cart.component.ts
β”‚ β”‚ β”œβ”€β”€ cart-item.component.ts
β”‚ β”‚ β”œβ”€β”€ cart.service.ts
β”‚ β”‚ └── cart.routes.ts
β”‚ └── admin/
β”‚ └── ...
β”œβ”€β”€ app.component.ts
β”œβ”€β”€ app.config.ts
└── app.routes.ts

Leverage Angular’s APP_INITIALIZER or injection tokens for runtime configuration.

config.token.ts
export interface AppConfig {
apiUrl: string;
production: boolean;
}
export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
{
provide: APP_CONFIG,
useValue: {
apiUrl: environment.apiUrl,
production: environment.production,
} satisfies AppConfig,
},
provideRouter(routes),
provideHttpClient(withInterceptors([apiInterceptor])),
],
};

Centralize error handling with a functional interceptor.

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
const notification = inject(NotificationService);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
switch (error.status) {
case 401:
router.navigate(['/login']);
break;
case 403:
notification.error('You do not have permission to perform this action.');
break;
case 404:
notification.error('The requested resource was not found.');
break;
case 500:
notification.error('A server error occurred. Please try again later.');
break;
}
return throwError(() => error);
}),
);
};

Always account for error states in your templates.

@Component({
selector: 'app-user-profile',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (error()) {
<div class="error-banner">
<p>{{ error() }}</p>
<button (click)="retry()">Retry</button>
</div>
} @else if (loading()) {
<app-skeleton-loader />
} @else if (user()) {
<h2>{{ user()!.name }}</h2>
<p>{{ user()!.email }}</p>
}
`,
})
export class UserProfileComponent {
private readonly userService = inject(UserService);
userId = input.required<string>();
user = signal<User | null>(null);
error = signal<string | null>(null);
loading = signal(true);
constructor() {
effect(() => {
this.loadUser(this.userId());
});
}
private loadUser(id: string) {
this.loading.set(true);
this.error.set(null);
this.userService.getUser(id).subscribe({
next: user => {
this.user.set(user);
this.loading.set(false);
},
error: () => {
this.error.set('Failed to load user profile.');
this.loading.set(false);
},
});
}
retry() {
this.loadUser(this.userId());
}
}

Angular components should produce accessible HTML. Use the host property in decorators for host-level ARIA bindings.

@Component({
selector: 'app-alert',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'role': 'alert',
'[attr.aria-live]': 'polite',
},
template: `
<div [class]="'alert alert-' + type()">
<ng-content />
</div>
`,
})
export class AlertComponent {
type = input<'info' | 'warning' | 'error'>('info');
}

When content changes dynamically (modals, route changes), manage focus programmatically.

@Component({
selector: 'app-search',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<label for="search-input">Search</label>
<input
id="search-input"
type="search"
[attr.aria-expanded]="showResults()"
aria-controls="search-results"
(input)="onSearch($event)"
/>
@if (showResults()) {
<ul id="search-results" role="listbox">
@for (result of results(); track result.id) {
<li role="option">{{ result.name }}</li>
}
</ul>
}
`,
})
export class SearchComponent {
results = signal<SearchResult[]>([]);
showResults = computed(() => this.results().length > 0);
onSearch(event: Event) {
const query = (event.target as HTMLInputElement).value;
// search logic...
}
}
PracticeDoDon’t
Change DetectionOnPush alwaysDefault change detection
StateSignals + computed()Manual change detection
Inputs/Outputsinput() / output()@Input / @Output
DIinject() functionConstructor injection
Control Flow@if / @for / @switch*ngIf / *ngFor
ServicesprovidedIn: 'root'Module-level providers
ImagesNgOptimizedImagePlain <img> tags
Stylingclass / style bindingsngClass / ngStyle
Host bindingshost property@HostBinding / @HostListener