Angular Best Practices: Architecture, Performance, and Quality in 2025

Angular is an opinionated framework — it makes many decisions for you. That's a strength in large teams, but it also means there's a clear gap between "code that compiles" and "code that scales." The patterns covered here separate Angular codebases that hold up over three years from ones that become unmaintainable after the first major feature.

1. Component Architecture: Smart vs. Dumb

The most impactful pattern in Angular component design is the split between smart (container) components and dumb (presentational) components. Smart components know about services, state, and routing. Dumb components receive data via @Input(), emit events via @Output(), and know nothing about the world outside them.

// ✗ Dumb component doing smart things — untestable in isolation @Component({ selector: 'app-user-card' }) export class UserCardComponent { user$ = inject(UserService).getUser(this.route.snapshot.params['id']); onDelete() { inject(UserService).delete(this.userId).subscribe(); } } // ✓ Smart container handles data; dumb component handles display @Component({ selector: 'app-user-page' }) // smart export class UserPageComponent { user$ = inject(UserService).getUser(this.route.snapshot.params['id']); onDelete(id: string) { inject(UserService).delete(id).subscribe(); } } @Component({ selector: 'app-user-card' }) // dumb export class UserCardComponent { @Input({ required: true }) user!: User; @Output() delete = new EventEmitter<string>(); }

Dumb components are trivial to test (just pass inputs, assert outputs), trivial to reuse, and trivial to move. Aim for 80% dumb components in any non-trivial Angular app.

2. OnPush Change Detection: Default for Everything

Angular's default change detection checks every component on every event. ChangeDetectionStrategy.OnPush restricts checks to when an @Input() reference changes, an async pipe resolves, or markForCheck() is called explicitly. On a page with 50+ components, this is the difference between smooth and jank.

// Set this on every component — make it your team's default @Component({ selector: 'app-user-card', changeDetection: ChangeDetectionStrategy.OnPush, template: `<div>{{ user.name }}</div>` }) export class UserCardComponent { @Input({ required: true }) user!: User; } // With OnPush, mutations to the same object won't trigger re-render // ✗ Won't update the view this.user.name = 'New Name'; // ✓ New reference — triggers re-render this.user = { ...this.user, name: 'New Name' };

Enforce it with a linter rule: Add @angular-eslint/prefer-on-push-component-change-detection to your ESLint config. The rule flags any component missing OnPush, making it a team standard rather than a personal discipline.

3. RxJS: The Five Pitfalls That Kill Angular Apps

Memory leaks from unmanaged subscriptions

// ✗ Subscription never cleaned up — leaks on component destroy ngOnInit() { this.userService.users$.subscribe(users => this.users = users); } // ✓ Option 1: async pipe (Angular manages lifecycle automatically) users$ = this.userService.users$; // template: {{ users$ | async }} // ✓ Option 2: takeUntilDestroyed (Angular 16+) private destroyRef = inject(DestroyRef); ngOnInit() { this.userService.users$ .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(users => this.users = users); }

Nested subscribes instead of flattening operators

// ✗ Nested subscribe — creates race conditions and leaks this.route.params.subscribe(params => { this.userService.getUser(params['id']).subscribe(user => { this.user = user; }); }); // ✓ switchMap cancels the inner observable when a new outer value arrives user$ = this.route.params.pipe( switchMap(params => this.userService.getUser(params['id'])) );

Wrong flattening operator

The four flattening operators are not interchangeable. Using the wrong one causes race conditions or dropped emissions silently:

// switchMap — cancels previous inner obs on new emission. Use: search, route params // mergeMap — runs all inner obs concurrently. Use: parallel, order doesn't matter // concatMap — queues inner obs sequentially. Use: ordered writes, upload queue // exhaustMap — ignores new emissions while inner obs is active. Use: login button // ✗ mergeMap on a search input — all requests run, responses arrive out of order searchTerm$.pipe(mergeMap(term => this.api.search(term))) // ✓ switchMap cancels stale requests automatically searchTerm$.pipe(debounceTime(300), switchMap(term => this.api.search(term)))

Sharing expensive observables

// ✗ Each subscriber triggers a new HTTP call users$ = this.http.get<User[]>('/api/users'); // ✓ shareReplay multicasts to all subscribers, replays last value to late subscribers users$ = this.http.get<User[]>('/api/users').pipe( shareReplay({ bufferSize: 1, refCount: true }) );

Mixing subscribe with async/await

Calling firstValueFrom() or lastValueFrom() to bridge RxJS and async/await is fine for one-shot operations. But mixing subscription patterns in the same component creates inconsistent teardown logic. Pick one style per component and be consistent.

4. Signals: The New State Primitive

Angular 16+ introduced Signals as a fine-grained reactivity primitive. Unlike observables, signals are synchronous, don't need unsubscription, and integrate natively with change detection — a component using a signal automatically re-renders when the signal changes, with no async pipe needed.

// Signals for local component state — cleaner than BehaviorSubject @Component({ template: ` <p>Count: {{ count() }}</p> <p>Double: {{ double() }}</p> <button (click)="increment()">+</button> ` }) export class CounterComponent { count = signal(0); double = computed(() => this.count() * 2); increment() { this.count.update(c => c + 1); } } // toSignal bridges RxJS observables to signals — best of both worlds users = toSignal(this.userService.users$, { initialValue: [] });

When to use Signals vs. Observables: Signals are ideal for local component state, derived UI values, and anything that doesn't involve async streams. Observables remain the right tool for HTTP calls, WebSocket streams, timer-based flows, and complex async transformations. toSignal() and toObservable() bridge the two where needed.

5. Standalone Components and Feature-Based Structure

Since Angular 14, components can be standalone — no NgModule required. Angular 17+ makes standalone the default. This removes one of the most common Angular pain points (where to declare a component) and makes lazy loading trivial.

// Feature-based folder structure (not layer-based) src/app/ ├── features/ │ ├── users/ │ │ ├── components/ # dumb components local to this feature │ │ ├── pages/ # smart components (routable) │ │ ├── services/ # data access, business logic │ │ ├── models/ # types and interfaces │ │ └── users.routes.ts # lazy-loaded route config │ └── orders/ ├── shared/ # components/pipes/directives used across features └── core/ # singleton services, guards, interceptors // Lazy loading a standalone component — one line { path: 'users', loadChildren: () => import('./features/users/users.routes') }

Feature-based structure scales better than layer-based (components/, services/, models/ at the root). When a feature grows, everything related to it lives in one place. When you remove a feature, you delete one folder.

6. Services and Dependency Injection

// ✓ providedIn: 'root' — singleton, tree-shakeable if unused @Injectable({ providedIn: 'root' }) export class UserService { ... } // ✓ inject() function — cleaner than constructor injection, works in standalone @Component({ ... }) export class UserPageComponent { private userService = inject(UserService); private router = inject(Router); } // ✓ Feature-scoped service — provided at the feature level, not root @Injectable() // no providedIn export class CartService { ... } // Provided in the feature route: { providers: [CartService] }

7. Performance: What Actually Moves the Needle

①
Lazy load every feature route
Initial bundle should contain only the shell (nav, root component). Every feature loaded on demand. loadChildren or loadComponent on every non-root route.
②
OnPush everywhere + trackBy on every *ngFor
Without trackBy, Angular re-creates every DOM node on list updates even if only one item changed. Always provide a unique key.
③
Avoid function calls in templates
{{ computeTotal() }} is called on every change detection cycle. Use computed(), a pure pipe, or a property instead. Functions in templates are one of the most common Angular performance bugs.
④
Defer heavy components with @defer
Angular 17+ @defer block loads a component's bundle only when triggered (viewport, interaction, idle). Charts, rich text editors, and third-party widgets are prime candidates.
In roughly this priority order — lazy loading and OnPush give the biggest return per effort

8. State Management: When to Reach for NgRx

NgRx is powerful and costly. The boilerplate is justified when: multiple unrelated components share the same state, state changes need to be auditable (time-travel debugging), or the team is large enough that unstructured state leads to conflicts. For everything else, services with Signals or BehaviorSubjects are sufficient and far simpler.

// Lightweight state service with Signals — covers 80% of use cases @Injectable({ providedIn: 'root' }) export class CartStateService { private items = signal<CartItem[]>([]); readonly count = computed(() => this.items().length); readonly total = computed(() => this.items().reduce((sum, item) => sum + item.price * item.qty, 0) ); add(item: CartItem) { this.items.update(list => [...list, item]); } remove(id: string) { this.items.update(list => list.filter(i => i.id !== id)); } readonly items$ = toObservable(this.items); // bridge to RxJS when needed }

9. Testing Strategy

Angular's TestBed is thorough but slow. A pragmatic testing strategy distinguishes three levels:

// Level 1: Pure unit tests — no TestBed, fast, for services and pure logic it('calculates total correctly', () => { const service = new CartStateService(); service.add({ id: '1', price: 10, qty: 2 }); expect(service.total()).toBe(20); }); // Level 2: Shallow component tests — TestBed but NO_ERRORS_SCHEMA for children await TestBed.configureTestingModule({ imports: [UserCardComponent], schemas: [NO_ERRORS_SCHEMA] // don't render child components }).compileComponents(); // Level 3: Integration — for critical user flows (login, checkout) with real child tree

The most common Angular testing mistake is over-relying on TestBed for everything. Dumb components don't need TestBed — just instantiate them, pass inputs, and assert on outputs. Reserve TestBed for components that interact with routing, DI, or the DOM in ways that require Angular's machinery.

10. HTTP Interceptors: The Right Place for Cross-Cutting Concerns

Interceptors sit between your code and every HTTP request/response. They're the correct place for auth token injection, error handling, loading indicators, and request logging — concerns that are universal but shouldn't pollute service methods. Angular 15+ supports functional interceptors, which are cleaner and don't require class boilerplate.

// auth.interceptor.ts — functional style (Angular 15+) export const authInterceptor: HttpInterceptorFn = (req, next) => { const token = inject(AuthService).getToken(); if (!token) return next(req); return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })); }; // error.interceptor.ts — global error handling with toast notifications export const errorInterceptor: HttpInterceptorFn = (req, next) => { const toast = inject(ToastService); return next(req).pipe( catchError((err: HttpErrorResponse) => { if (err.status === 401) inject(AuthService).logout(); else if (err.status >= 500) toast.error('Server error. Please try again.'); return throwError(() => err); }) ); }; // loading.interceptor.ts — global loading state export const loadingInterceptor: HttpInterceptorFn = (req, next) => { const loading = inject(LoadingService); loading.start(); return next(req).pipe(finalize(() => loading.stop())); }; // Register in app.config.ts export const appConfig: ApplicationConfig = { providers: [ provideHttpClient(withInterceptors([authInterceptor, errorInterceptor, loadingInterceptor])) ] };

11. Route Guards and Resolvers

Route guards control navigation access; resolvers pre-fetch data before a route activates. Both use functional form in Angular 15+. A common mistake is fetching data in ngOnInit() and showing a loading spinner — using a resolver instead means the component receives its data already populated, with no loading state to manage.

// auth.guard.ts — functional guard (Angular 15+) export const authGuard: CanActivateFn = () => { const auth = inject(AuthService); const router = inject(Router); return auth.isAuthenticated() ? true : router.createUrlTree(['/login']); }; // role.guard.ts — with parameterized roles export const roleGuard = (requiredRole: string): CanActivateFn => () => { const auth = inject(AuthService); return auth.hasRole(requiredRole) ? true : inject(Router).createUrlTree(['/forbidden']); }; // user.resolver.ts — data is ready when component activates export const userResolver: ResolveFn<User> = (route) => inject(UserService).getUser(route.paramMap.get('id')!); // Route configuration { path: 'users/:id', component: UserDetailPage, canActivate: [authGuard, roleGuard('admin')], resolve: { user: userResolver } // available as route.data['user'] }

12. Reactive Forms: Validation That Scales

Reactive Forms give you a typed, testable model of your form state. They handle complex validation scenarios — cross-field validation, async validators (email availability check), dynamic form arrays — that template-driven forms struggle with. The key: define the form structure and its validators in the component class, not in the template.

// ✓ Strongly typed form with cross-field and async validators form = new FormGroup({ email: new FormControl('', [Validators.required, Validators.email], [this.emailAvailableValidator()]), // async password: new FormControl('', [Validators.required, Validators.minLength(8)]), confirmPassword: new FormControl('', Validators.required), }, { validators: passwordMatchValidator }); // cross-field // Custom cross-field validator const passwordMatchValidator: ValidatorFn = (group) => { const pw = group.get('password')?.value; const confirm = group.get('confirmPassword')?.value; return pw === confirm ? null : { passwordMismatch: true }; }; // Async validator — debounced HTTP call emailAvailableValidator(): AsyncValidatorFn { return (ctrl) => timer(400).pipe( switchMap(() => this.authService.checkEmail(ctrl.value)), map(taken => taken ? { emailTaken: true } : null), catchError(() => of(null)) ); } // FormArray — dynamic list of items items = new FormArray<FormGroup>([]); addItem() { this.items.push(new FormGroup({ name: new FormControl('', Validators.required), quantity: new FormControl(1, [Validators.required, Validators.min(1)]) })); }

13. Common Mistakes That Compound Over Time

Key Takeaways

Back to all articles