Angular Best Practices
Great Angular code is readable, maintainable, testable, and performant. This guide distills the community's collective wisdom and Angular team recommendations into actionable best practices.
Project Structure
Organize your code by feature, not by type. Feature-based organization scales much better as the app grows:
src/ ├── app/ │ ├── core/ # Singleton services, guards, interceptors │ │ ├── auth/ │ │ │ ├── auth.service.ts │ │ │ └── auth.guard.ts │ │ └── http/ │ │ └── error.interceptor.ts │ ├── shared/ # Reusable components, pipes, directives │ │ ├── components/ │ │ │ └── button/ │ │ └── pipes/ │ ├── features/ # Feature modules/components │ │ ├── dashboard/ │ │ │ ├── dashboard.component.ts │ │ │ ├── dashboard.routes.ts │ │ │ └── services/ │ │ └── products/ │ │ ├── product-list/ │ │ ├── product-detail/ │ │ └── services/ │ ├── app.routes.ts │ └── app.config.ts
Standalone Components First
Angular 17+ defaults to standalone components. Prefer them over NgModules for all new code:
// ✅ Standalone — preferred for new code
@Component({
standalone: true,
imports: [CommonModule, RouterLink, UserCardComponent],
selector: 'app-user-list',
template: `...`,
})
export class UserListComponent {}
// ❌ Module-based — legacy pattern
@NgModule({
declarations: [UserListComponent],
imports: [CommonModule],
})
export class UsersModule {}Use inject() Over Constructor Injection
The inject() function (Angular 14+) is cleaner, more tree-shakeable, and works in
injection contexts outside constructors:
// ❌ Old pattern — constructor injection
export class ProductService {
constructor(
private http: HttpClient,
private router: Router,
private authService: AuthService,
) {}
}
// ✅ New pattern — inject() function
export class ProductService {
private http = inject(HttpClient);
private router = inject(Router);
private authService = inject(AuthService);
}
// inject() also works in standalone functions (guards, resolvers, etc.)
export const authGuard = () => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isLoggedIn() ? true : router.createUrlTree(['/login']);
};Signals for State Management
Use Signals for component state. They're more performant than properties and easier to reason about:
// ✅ Signals — reactive, performant, explicit
@Component({ standalone: true, template: `{{ count() }}` })
export class CounterComponent {
count = signal(0);
doubled = computed(() => this.count() * 2);
increment() { this.count.update((n) => n + 1); }
}
// For service state — use signals in services
@Injectable({ providedIn: 'root' })
export class CartService {
private items = signal<CartItem[]>([]);
readonly itemCount = computed(() => this.items().length);
readonly total = computed(() =>
this.items().reduce((sum, item) => sum + item.price, 0)
);
addItem(item: CartItem) {
this.items.update((items) => [...items, item]);
}
removeItem(id: number) {
this.items.update((items) => items.filter((i) => i.id !== id));
}
}Smart vs. Dumb Components
Separate smart components (connected to services/state) from dumb/presentational components (receive data as inputs, emit events as outputs):
// ✅ Smart component — handles data fetching and state
@Component({
standalone: true,
imports: [UserCardComponent],
template: `
@for (user of users(); track user.id) {
<app-user-card [user]="user" (delete)="deleteUser($event)" />
}
`,
})
export class UsersPageComponent {
private userService = inject(UserService);
users = this.userService.users; // signal
deleteUser(id: number) {
this.userService.delete(id);
}
}
// ✅ Dumb component — pure presentation, no service dependencies
@Component({
standalone: true,
selector: 'app-user-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="card">
<h3>{{ user.name }}</h3>
<button (click)="delete.emit(user.id)">Delete</button>
</div>
`,
})
export class UserCardComponent {
@Input({ required: true }) user!: User;
@Output() delete = new EventEmitter<number>();
}OnPush Change Detection Everywhere
All dumb/presentational components should use OnPush. Apply it as a default:
// Apply OnPush to all presentational components
@Component({
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
// ...
})
export class AnyPresentationalComponent {}
// Rule of thumb:
// - Dumb components → always OnPush
// - Smart components with Signals → OnPush (signals handle updates)
// - Smart components with Observables + async pipe → OnPush (async pipe triggers CD)Async Pipe Over Manual Subscriptions
The async pipe automatically subscribes and unsubscribes when the component is destroyed,
preventing memory leaks:
// ❌ Manual subscription — must remember to unsubscribe
export class UserListComponent implements OnInit, OnDestroy {
users: User[] = [];
private sub!: Subscription;
ngOnInit() {
this.sub = this.userService.getUsers().subscribe(
(users) => (this.users = users)
);
}
ngOnDestroy() {
this.sub.unsubscribe(); // Easy to forget!
}
}
// ✅ Async pipe — automatic subscription management
@Component({
imports: [AsyncPipe],
template: `
@for (user of users$ | async; track user.id) {
<app-user-card [user]="user" />
}
`,
})
export class UserListComponent {
users$ = this.userService.getUsers();
}Avoid Memory Leaks
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DestroyRef, inject } from '@angular/core';
// ✅ Angular 16+ — takeUntilDestroyed() (cleanest)
export class MyComponent {
constructor() {
this.dataService.stream$.pipe(
takeUntilDestroyed() // Automatically completes when component destroys
).subscribe(data => console.log(data));
}
}
// ✅ Angular 16+ — inject(DestroyRef) for services
@Injectable()
export class MyService {
private destroyRef = inject(DestroyRef);
startTracking() {
interval(1000).pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe();
}
}Strict TypeScript Configuration
// tsconfig.json — enable strict mode
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictPropertyInitialization": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"angularCompilerOptions": {
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true // Type-check template bindings
}
}strictTemplates: true enables Angular's template type checker, which catches type errors in your HTML templates at build time — e.g., passing a string where a number is expected.ESLint Configuration
ng add @angular-eslint/schematics
// .eslintrc.json
{
"overrides": [
{
"files": ["*.ts"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@angular-eslint/recommended"
],
"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/no-explicit-any": "warn",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}
]
}Error Handling
// Global error handler
import { ErrorHandler, Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
private router = inject(Router);
handleError(error: Error) {
console.error('Global error:', error);
// Send to monitoring service (e.g., Sentry)
this.monitoringService.captureException(error);
if (error.message.includes('ChunkLoadError')) {
// Reload if a lazy chunk fails to load (e.g., after deployment)
window.location.reload();
}
}
}
// Register in app.config.ts
providers: [
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
]Security Best Practices
Never use [innerHTML] with user-supplied content — Angular sanitizes it but DOMPurify is safer
Use Angular's HttpClient — it automatically sends XSRF tokens
Never store sensitive data in localStorage — use HttpOnly cookies instead
Avoid bypassSecurityTrustHtml/Url unless absolutely necessary — each use is a security risk
Use route guards to protect authenticated routes, not CSS visibility
Set Content Security Policy headers on your server
Regularly run ng audit (npm audit) to check for vulnerable dependencies
bypassSecurityTrustHtml() with user-generated content. Angular's sanitizer removes dangerous HTML, but bypassing it opens you to XSS attacks.Summary: The Angular 17+ Best Practices Stack
Concern | Recommended Approach |
|---|---|
Components | Standalone, OnPush, inject() |
State | Signals + computed() for local; NgRx Signal Store for global |
HTTP | HttpClient + async pipe + shareReplay(1) |
Forms | Reactive Forms with typed FormGroup |
Routing | Lazy loading with loadComponent/loadChildren |
Testing | TestBed for components, Jest for speed |
Linting | @angular-eslint + Prettier |
TypeScript | strict: true + strictTemplates: true |
Subscriptions | takeUntilDestroyed() or async pipe |
Rendering | @defer for heavy components, NgOptimizedImage for images |