Component Design Best Practices
Well-designed components are the foundation of a maintainable Angular application. The goal is to keep each component small, focused on a single responsibility, and as presentational as possible — pushing data fetching and business logic toward services and a thin layer of smart “container” components. When you combine that discipline with OnPush change detection, signals, and a clean inputs/outputs contract, your UI becomes faster, easier to test, and far simpler to reason about.
Keep components small and single-purpose
A component should do one thing. If a class touches HTTP, formats data, manages a form, and renders a list, it is doing four things — and each is harder to test in isolation. Split along responsibilities: a container component coordinates data and state, while presentational components only render inputs and emit outputs.
// user-card.component.ts — presentational: no services, no HTTP
import { Component, input, output, ChangeDetectionStrategy } from '@angular/core';
import { User } from './user.model';
@Component({
selector: 'app-user-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<article class="card">
<h3>{{ user().name }}</h3>
<p>{{ user().email }}</p>
<button (click)="select.emit(user().id)">Select</button>
</article>
`,
})
export class UserCardComponent {
readonly user = input.required<User>();
readonly select = output<string>();
}
The card knows nothing about where users come from. It receives a User and emits an id — that is its entire contract, which makes it trivially reusable and testable.
Favor inputs and outputs over tight coupling
Presentational components should never reach into shared services or global state. Data flows down through inputs; events flow up through outputs. This unidirectional flow keeps components decoupled and predictable.
// user-list.component.ts — container: owns data, delegates rendering
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { UserCardComponent } from './user-card.component';
import { UserService } from './user.service';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [UserCardComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (user of users(); track user.id) {
<app-user-card [user]="user" (select)="onSelect($event)" />
} @empty {
<p>No users yet.</p>
}
`,
})
export class UserListComponent {
private readonly service = inject(UserService);
readonly users = this.service.users; // a signal exposed by the service
onSelect(id: string): void {
this.service.select(id);
}
}
The modern input() and output() functions are signal-based and type-safe, replacing the older @Input()/@Output() decorators. Use input.required<T>() when a value must always be provided — Angular will report a clear error if it is missing.
Tip: Use
trackin every@forloop. Without a stable track expression Angular re-creates DOM nodes on each change, which destroys performance and resets element state like focus or scroll position.
Use OnPush change detection
By default Angular checks every component on every event anywhere in the app. ChangeDetectionStrategy.OnPush tells Angular to re-check a component only when one of its inputs changes by reference, an event fires inside it, or a bound signal/observable emits. This dramatically reduces work in large trees.
OnPush pairs naturally with immutable data and signals. Because signals notify Angular precisely when their value changes, an OnPush component reading a signal updates exactly when it should — no manual markForCheck() needed.
import { Component, signal, computed, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-cart-summary',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Items: {{ count() }}</p>
<p>Total: {{ total() | currency }}</p>
<button (click)="add(9.99)">Add item</button>
`,
})
export class CartSummaryComponent {
private readonly prices = signal<number[]>([]);
readonly count = computed(() => this.prices().length);
readonly total = computed(() => this.prices().reduce((a, b) => a + b, 0));
add(price: number): void {
this.prices.update((list) => [...list, price]); // new array → reference change
}
}
Note the update uses a new array rather than mutating in place. With OnPush and immutable updates, change detection stays correct and fast.
Smart vs presentational at a glance
| Aspect | Container (smart) | Presentational (dumb) |
|---|---|---|
| Injects services | Yes | No |
| Knows about state/HTTP | Yes | No |
| Inputs / outputs | Few | Many, well-defined |
| Reusability | Low (app-specific) | High |
| Easy to unit test | Moderate | Very easy |
| Change detection | OnPush | OnPush |
Write components that are easy to test
A presentational component with a clean input/output contract needs no HttpClient, no router, and no mocking ceremony. You set inputs and assert on rendered output or emitted events.
import { TestBed } from '@angular/core/testing';
import { UserCardComponent } from './user-card.component';
it('emits the user id on select', () => {
const fixture = TestBed.createComponent(UserCardComponent);
fixture.componentRef.setInput('user', { id: '42', name: 'Ada', email: '[email protected]' });
fixture.detectChanges();
let emitted: string | undefined;
fixture.componentInstance.select.subscribe((id) => (emitted = id));
fixture.nativeElement.querySelector('button').click();
expect(emitted).toBe('42');
});
Output:
PASS src/app/user-card.component.spec.ts
✓ emits the user id on select (18 ms)
Warning: Avoid putting heavy logic in templates or getters. Method calls in bindings run on every change-detection cycle. Move derived values into
computed()signals so they are memoized and only recalculated when their dependencies change.
Best Practices
- Keep each component focused on a single responsibility; extract presentational pieces from containers.
- Use signal-based
input()/output()and a minimal, explicit contract — never reach into shared services from a presentational component. - Enable
ChangeDetectionStrategy.OnPushon every component and pair it with immutable updates and signals. - Always provide a stable
trackexpression in@forto preserve DOM identity and performance. - Replace template method calls and getters with
computed()signals for memoized derived state. - Keep templates declarative; move branching and formatting logic into the class or pure helpers.
- Prefer
input.required<T>()to make mandatory inputs fail loudly and early.