5 Best Practices for Modern Angular Development
An opinionated article on Angular features and methods
I’ve been revisiting Angular with these last few versions, mainly because I like that it forces structure — something I’ve come to appreciate and be reminded of more recently. Angular has evolved dramatically over the years, and keeping up with best practices is crucial for building high-performing, maintainable applications. Whether you're just starting with Angular or looking to level up your existing projects, these five modern practices will dramatically improve your development workflow and results.
1. Embrace Standalone Components & Signals (Angular v16+)
Remember the days of juggling complex NgModules? Yeah, those days are over. With Angular 16+, the framework is moving toward a simplified component model that makes development faster and more intuitive.
Standalone Components
Standalone components cut through the module bureaucracy. Instead of declaring components in a module, you can simply mark them as standalone:
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { UserAvatarComponent } from '../shared/user-avatar/user-avatar.component';
interface UserProfile {
id: string;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
lastLogin: Date;
}
@Component({
selector: 'app-user-profile',
templateUrl: './user-profile.component.html',
styleUrls: ['./user-profile.component.scss'],
standalone: true,
imports: [CommonModule, RouterModule, UserAvatarComponent]
})
export class UserProfileComponent {
currentUser: UserProfile | null = null;
isLoading: boolean = true;
// More strongly typed component logic
}
Signals for Reactive State
Signals provide a streamlined alternative to RxJS for simple reactivity. They're lightweight, intuitive, and specifically designed for Angular's change detection:
import { Component, signal, computed, Signal, WritableSignal } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-counter',
template: `
<h1>{{ count() }}</h1>
<h2>Doubled: {{ doubledCount() }}</h2>
<button (click)="increment()">Increment</button>
<button (click)="incrementBy(5)">+5</button>
`,
standalone: true,
imports: [CommonModule]
})
export class CounterComponent {
// Create a signal with initial value 0 with explicit type
count: WritableSignal<number> = signal(0);
// Create a computed value that depends on count with explicit return type
doubledCount: Signal<number> = computed(() => this.count() * 2);
increment(): void {
// Update the signal value
this.count.update((value: number) => value + 1);
}
incrementBy(amount: number): void {
this.count.update((value: number) => value + amount);
}
}
Pros of Standalone Components & Signals:
Simpler mental model with less boilerplate code
Improved tree-shaking for smaller bundle sizes
Better developer experience with clearer dependencies
Gradual adoption path - you can mix with traditional components
Fine-grained reactivity with signals that integrate with Angular's change detection
2. Optimize State Management
As your application grows, managing state effectively becomes critical. Angular offers several approaches, each suited to different complexity levels.
RxJS for Local State
For component-specific state, RxJS observables provide a powerful reactive approach:
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { BehaviorSubject, Subject, Observable } from 'rxjs';
import { takeUntil, debounceTime, distinctUntilChanged, tap, switchMap } from 'rxjs/operators';
interface SearchResult {
id: string;
name: string;
description: string;
relevanceScore: number;
}
@Component({
selector: 'app-search',
template: `
<input [formControl]="searchControl">
<div *ngIf="loading$ | async">Loading...</div>
<div *ngFor="let result of results$ | async">{{ result.name }}</div>
`,
standalone: true,
imports: [CommonModule, ReactiveFormsModule]
})
export class SearchComponent implements OnInit, OnDestroy {
searchControl: FormControl<string | null> = new FormControl('');
loading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
results$: BehaviorSubject<SearchResult[]> = new BehaviorSubject<SearchResult[]>([]);
private destroy$: Subject<void> = new Subject<void>();
constructor(private searchService: SearchService) {}
ngOnInit(): void {
this.searchControl.valueChanges.pipe(
takeUntil(this.destroy$),
debounceTime(300),
distinctUntilChanged(),
tap(() => this.loading$.next(true)),
switchMap((term: string | null) =>
this.searchService.search(term || '')
)
).subscribe((results: SearchResult[]) => {
this.results$.next(results);
this.loading$.next(false);
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
NgRx for Application-Wide State
For larger applications, NgRx provides a robust Redux implementation with full TypeScript support:
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { createAction, props, createReducer, on, createSelector } from '@ngrx/store';
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Observable, EMPTY } from 'rxjs';
import { map, switchMap, catchError } from 'rxjs/operators';
// Define the User interface
interface User {
id: number;
name: string;
email: string;
role: string;
}
// Define state interface
interface UserState {
users: User[];
loading: boolean;
error: string | null;
}
const initialState: UserState = {
users: [],
loading: false,
error: null
};
// Actions with TypeScript
export const loadUsers = createAction('[Users] Load');
export const loadUsersSuccess = createAction(
'[Users] Load Success',
props<{ users: User[] }>()
);
export const loadUsersFailure = createAction(
'[Users] Load Failure',
props<{ error: string }>()
);
// Selectors with TypeScript
export const selectUserState = (state: { users: UserState }) => state.users;
export const selectUsers = createSelector(
selectUserState,
(state: UserState) => state.users
);
export const selectUsersLoading = createSelector(
selectUserState,
(state: UserState) => state.loading
);
// Reducer with TypeScript
export const userReducer = createReducer<UserState>(
initialState,
on(loadUsers, (state): UserState => ({
...state,
loading: true,
error: null
})),
on(loadUsersSuccess, (state, { users }): UserState => ({
...state,
users,
loading: false
})),
on(loadUsersFailure, (state, { error }): UserState => ({
...state,
loading: false,
error
}))
);
// Effect with TypeScript
@Injectable()
export class UserEffects {
loadUsers$ = createEffect(() => this.actions$.pipe(
ofType(loadUsers),
switchMap(() => this.userService.getUsers().pipe(
map((users: User[]) => loadUsersSuccess({ users })),
catchError((error: Error) => [loadUsersFailure({ error: error.message })])
))
));
constructor(
private actions$: Actions,
private userService: UserService
) {}
}
// Component with TypeScript
@Component({
selector: 'app-user-list',
template: `
<div *ngIf="loading$ | async">Loading...</div>
<div *ngIf="error$ | async as error" class="error">{{ error }}</div>
<div *ngFor="let user of users$ | async">{{ user.name }}</div>
`,
standalone: true,
imports: [CommonModule]
})
export class UserListComponent implements OnInit {
users$: Observable<User[]> = this.store.select(selectUsers);
loading$: Observable<boolean> = this.store.select(selectUsersLoading);
error$: Observable<string | null> = this.store.select(state => state.users.error);
constructor(private store: Store<{ users: UserState }>) {}
ngOnInit(): void {
this.store.dispatch(loadUsers());
}
}
Component Store for Mid-Size Features
For medium-complexity features, @ngrx/component-store strikes a nice balance:
import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { EMPTY, Observable } from 'rxjs';
import { switchMap, tap, catchError } from 'rxjs/operators';
// Define the Todo interface
interface Todo {
id: number;
title: string;
completed: boolean;
dueDate?: Date;
priority: 'low' | 'medium' | 'high';
}
// Define the state interface
interface TodosState {
todos: Todo[];
loading: boolean;
error: string | null;
filter: 'all' | 'active' | 'completed';
}
@Injectable()
export class TodosStore extends ComponentStore<TodosState> {
constructor(private todosService: TodosService) {
super({
todos: [],
loading: false,
error: null,
filter: 'all'
});
}
// Selectors
readonly todos$: Observable<Todo[]> = this.select(state => state.todos);
readonly loading$: Observable<boolean> = this.select(state => state.loading);
readonly error$: Observable<string | null> = this.select(state => state.error);
// Computed selector for filtered todos
readonly filteredTodos$: Observable<Todo[]> = this.select(
this.select(state => state.todos),
this.select(state => state.filter),
(todos, filter) => {
switch (filter) {
case 'active':
return todos.filter(todo => !todo.completed);
case 'completed':
return todos.filter(todo => todo.completed);
default:
return todos;
}
}
);
// Effects
readonly loadTodos = this.effect<void>(trigger$ => trigger$.pipe(
tap(() => this.patchState({ loading: true, error: null })),
switchMap(() => this.todosService.getTodos().pipe(
tap((todos: Todo[]) => this.patchState({
todos,
loading: false
})),
catchError((error: Error) => {
this.patchState({
loading: false,
error: error.message
});
return EMPTY;
})
))
));
// Updaters
readonly addTodo = this.updater((state: TodosState, todo: Todo): TodosState => ({
...state,
todos: [...state.todos, todo]
}));
readonly toggleTodo = this.updater((state: TodosState, todoId: number): TodosState => ({
...state,
todos: state.todos.map(todo =>
todo.id === todoId ? { ...todo, completed: !todo.completed } : todo
)
}));
readonly setFilter = this.updater(
(state: TodosState, filter: 'all' | 'active' | 'completed'): TodosState => ({
...state,
filter
})
);
}
Pros of Modern State Management:
Predictable state changes with unidirectional data flow
Developer tools for time-travel debugging (with NgRx)
Scalable architecture that grows with your application
Testability through isolated, pure functions
Performance optimization by avoiding unnecessary computations
3. Lazy Loading & Code Splitting
Nobody likes waiting for apps to load. Lazy loading dramatically improves initial load times by only loading what's needed.
Route-Level Lazy Loading
The most common approach is to lazy-load feature modules by route:
import { Routes } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from './auth/auth.service';
// Type for route data
interface CustomRouteData {
title: string;
preload?: boolean;
requiredRole?: 'admin' | 'user' | 'guest';
}
// Define typed routes
const routes: Routes = [
{
path: 'admin',
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule),
canActivate: [() => inject(AuthService).hasRole('admin')],
data: {
title: 'Admin Panel',
requiredRole: 'admin'
} as CustomRouteData
},
{
path: 'dashboard',
loadComponent: () => import('./dashboard/dashboard.component')
.then(c => c.DashboardComponent),
canActivate: [() => inject(AuthService).isAuthenticated()],
data: {
title: 'Dashboard',
preload: true
} as CustomRouteData
},
{
path: 'reports',
loadChildren: () => import('./reports/reports.routes')
.then(routes => routes.REPORTS_ROUTES),
data: {
title: 'Reports',
preload: true,
requiredRole: 'user'
} as CustomRouteData
}
];
PreloadingStrategy for Improved UX
Want the benefits of lazy loading without making users wait? PreloadingStrategy lets you load modules in the background:
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
const routes: Routes = [ /* routes here */ ];
@NgModule({
imports: [
RouterModule.forRoot(routes, {
preloadingStrategy: PreloadAllModules
})
],
exports: [RouterModule]
})
export class AppRoutingModule { }
Custom Preloading Strategies
For even more control, create custom preloading strategies:
@Injectable()
export class SelectivePreloadingStrategy implements PreloadingStrategy {
preloadedModules: string[] = [];
preload(route: Route, load: () => Observable<any>): Observable<any> {
if (route.data?.preload && route.path) {
// Add the route path to our preloaded modules array
this.preloadedModules.push(route.path);
return load();
}
return of(null);
}
}
// In your route configuration
const routes: Routes = [
{
path: 'customers',
loadChildren: () => import('./customers/customers.module').then(m => m.CustomersModule),
data: { preload: true }
}
];
Pros of Lazy Loading & Code Splitting:
Faster initial load times by downloading only what's needed
Better performance on mobile devices with smaller bundles
Improved user experience with strategic preloading
Reduced memory usage for users who only access part of your app
Natural code organization around feature boundaries
4. Strict TypeScript & Linting
The best bugs are the ones that never make it to production. Strict TypeScript configurations and linting catch errors before they can cause problems.
Enable Strict TypeScript
In your tsconfig.json
, enable strict mode for maximum type safety:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true
}
}
ESLint with Angular-Specific Rules
Configure ESLint with Angular-specific rules:
// .eslintrc.js
import type { Linter } from 'eslint';
// Using TypeScript for ESLint configuration
const config: Linter.Config = {
root: true,
overrides: [
{
files: ['*.ts'],
parserOptions: {
project: ['tsconfig.json'],
createDefaultProgram: true
},
extends: [
'plugin:@angular-eslint/recommended',
'plugin:@angular-eslint/template/process-inline-templates',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking'
],
rules: {
'@angular-eslint/directive-selector': [
'error',
{ type: 'attribute', prefix: 'app', style: 'camelCase' }
],
'@angular-eslint/component-selector': [
'error',
{ type: 'element', prefix: 'app', style: 'kebab-case' }
],
'@typescript-eslint/explicit-function-return-type': ['error', {
allowExpressions: true,
allowTypedFunctionExpressions: true,
}],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/unbound-method': 'error'
}
},
{
files: ['*.html'],
extends: ['plugin:@angular-eslint/template/recommended'],
rules: {
'@angular-eslint/template/no-negated-async': 'error',
'@angular-eslint/template/accessibility-alt-text': 'error',
'@angular-eslint/template/accessibility-elements-content': 'error',
'@angular-eslint/template/accessibility-label-has-associated-control': 'error'
}
}
]
};
export default config;
Enforce Consistent Style with Prettier
Add Prettier for consistent code formatting:
// .prettierrc
{
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2,
"semi": true
}
Type-Safe HTTP Requests
Always use typed HTTP requests:
import { Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, map, retry } from 'rxjs/operators';
import { environment } from '../environments/environment';
// User related interfaces
interface UserRole {
id: number;
name: 'admin' | 'user' | 'guest';
permissions: string[];
}
interface UserAddress {
street: string;
city: string;
state: string;
zipCode: string;
country: string;
}
interface User {
id: number;
name: string;
email: string;
role: UserRole;
isActive: boolean;
lastLoginDate?: Date;
address?: UserAddress;
preferences: Record<string, unknown>;
}
// API response interfaces
interface ApiResponse<T> {
data: T;
metadata: {
timestamp: string;
requestId: string;
};
}
interface UserListResponse extends ApiResponse<User[]> {
pagination: {
totalItems: number;
currentPage: number;
pageSize: number;
totalPages: number;
};
}
// Request parameter interfaces
interface UserFilterParams {
active?: boolean;
role?: string;
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortDirection?: 'asc' | 'desc';
}
// Custom errors
class ApiError extends Error {
constructor(
public statusCode: number,
public message: string,
public errorCode: string
) {
super(message);
this.name = 'ApiError';
}
}
@Injectable({
providedIn: 'root'
})
export class UserService {
private apiUrl: string = `${environment.apiBaseUrl}/users`;
constructor(private http: HttpClient) {}
getUsers(filters?: UserFilterParams): Observable<UserListResponse> {
let params = new HttpParams();
if (filters) {
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
params = params.set(key, value.toString());
}
});
}
return this.http.get<UserListResponse>(this.apiUrl, { params }).pipe(
retry(1),
catchError(this.handleError)
);
}
getUser(id: number): Observable<User> {
return this.http.get<ApiResponse<User>>(`${this.apiUrl}/${id}`).pipe(
map(response => response.data),
catchError(this.handleError)
);
}
createUser(user: Omit<User, 'id'>): Observable<User> {
return this.http.post<ApiResponse<User>>(this.apiUrl, user).pipe(
map(response => response.data),
catchError(this.handleError)
);
}
updateUser(id: number, user: Partial<User>): Observable<User> {
return this.http.put<ApiResponse<User>>(`${this.apiUrl}/${id}`, user).pipe(
map(response => response.data),
catchError(this.handleError)
);
}
deleteUser(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(
catchError(this.handleError)
);
}
private handleError(error: HttpErrorResponse): Observable<never> {
console.error('API error:', error);
if (error.error instanceof ErrorEvent) {
// Client-side error
return throwError(() => new Error('Network error occurred. Please check your connection.'));
} else {
// Server-side error
const statusCode = error.status;
const message = error.error?.message || 'Unknown server error occurred.';
const errorCode = error.error?.code || 'UNKNOWN_ERROR';
return throwError(() => new ApiError(statusCode, message, errorCode));
}
}
}
Pros of Strict TypeScript & Linting:
Catch errors during development instead of runtime
Improved IDE assistance with better autocomplete
Self-documenting code through explicit types
Safer refactoring with compile-time checks
Consistent codebase that's easier to maintain
5. Efficient Change Detection & Performance Optimization
Angular's default change detection is powerful but can be a performance bottleneck in complex applications. Optimize it for maximum speed.
OnPush Change Detection
Switch to OnPush change detection for components that don't need frequent updates:
import { Component, Input, ChangeDetectionStrategy, OnChanges, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
interface UserCardData {
id: number;
name: string;
email: string;
role: string;
avatarUrl?: string;
}
@Component({
selector: 'app-user-card',
template: `
<div class="card" [class.premium]="isPremiumUser">
<div class="avatar" *ngIf="user.avatarUrl">
<img [src]="user.avatarUrl" [alt]="user.name + ' avatar'" />
</div>
<div class="user-info">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
<span class="role-badge" [class]="'role-' + user.role.toLowerCase()">
{{ user.role }}
</span>
</div>
</div>
`,
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserCardComponent implements OnChanges {
@Input({ required: true }) user!: UserCardData;
isPremiumUser: boolean = false;
ngOnChanges(changes: SimpleChanges): void {
if (changes['user']) {
this.isPremiumUser = this.checkIfPremium(this.user);
}
}
private checkIfPremium(user: UserCardData): boolean {
return user.role.toLowerCase() === 'premium' || user.role.toLowerCase() === 'admin';
}
}
TrackBy for NgFor
Use trackBy with ngFor to prevent unnecessary DOM recreation:
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
### Detach Change Detection for Infrequently Updated Components
For components that rarely update, manually control change detection:
```typescript
@Component({
selector: 'app-static-widget',
template: `<div>{{ expensiveComputation() }}</div>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StaticWidgetComponent implements OnInit {
constructor(private cdr: ChangeDetectorRef) {}
ngOnInit() {
// Detach this component from change detection
this.cdr.detach();
// Update once every minute
setInterval(() => {
this.cdr.detectChanges();
}, 60000);
}
expensiveComputation() {
// Some expensive calculation
return result;
}
}
Pure Pipes for Expensive Transformations
Use pure pipes for computations instead of component methods:
@Pipe({
name: 'filter',
pure: true
})
export class FilterPipe implements PipeTransform {
transform(items: any[], property: string, value: any): any[] {
if (!items) return [];
if (!value) return items;
return items.filter(item => item[property] === value);
}
}
// In your template
<div *ngFor="let user of users | filter:'status':'active'; trackBy: trackByUserId">
{{ user.name }}
</div>
Pros of Efficient Change Detection:
Dramatically improved rendering performance for large applications
Smoother user experience with fewer frame drops
Reduced CPU usage and battery consumption on mobile devices
Better scalability as your application grows
More predictable behavior in complex component trees
Conclusion
Modern Angular development has evolved significantly from the early days. By embracing standalone components and signals, implementing effective state management, lazy loading features, enforcing strict TypeScript, and optimizing change detection, you can build applications that are both powerful and performant.
These best practices not only make your code more efficient but also more maintainable and scalable as your projects grow. The Angular ecosystem continues to evolve, but these foundational principles will serve you well regardless of what new features come in future versions.