Reader Stacks

Building Reusable Components in Angular

A component becomes genuinely reusable through its public API — @Input/@Output for data, and content projection (ng-content) for letting the parent supply arbitrary markup, not just values.

A reusable component isn't just one that happens to get used more than once — it's one with a deliberately designed public interface, so a consumer can configure it and plug content into it without needing to know or care how it's implemented internally.

1. @Input and @Output define the basic contract

@Component({
  selector: 'app-badge',
  template: `<span class="badge" [class]="'badge--' + variant">{{ label }}</span>`,
})
export class BadgeComponent {
  @Input() label = '';
  @Input() variant: 'success' | 'warning' | 'error' = 'success';
}
<app-badge label="Active" variant="success"></app-badge>

Typing variant as a union of specific string literals, rather than a plain string, is what actually makes this reusable safely — TypeScript catches an invalid value like variant="succes" (a typo) at compile time, rather than the component silently rendering an unstyled or broken badge at runtime.

2. Content projection with ng-content

@Component({
  selector: 'app-card',
  template: `
    <div class="card">
      <div class="card-header"><ng-content select="[card-title]"></ng-content></div>
      <div class="card-body"><ng-content></ng-content></div>
    </div>
  `,
})
export class CardComponent {}
<app-card>
  <h3 card-title>Order #4821</h3>
  <p>Shipped on March 3rd.</p>
</app-card>

This is the piece @Input alone can't cover — an @Input passes a value in, but the parent still can't supply arbitrary markup (a heading with its own formatting, a nested component, several paragraphs) into a specific slot inside the child's template without content projection. <ng-content select="..."> creates a named slot; a plain <ng-content> with no select catches anything not matched by a more specific slot.

3. Sensible, safe defaults

@Input() size: 'sm' | 'md' | 'lg' = 'md';
@Input() disabled = false;

Every @Input should work reasonably with no value supplied at all — a consumer using <app-badge label="New"></app-badge> with no variant specified should still render something sensible, not an error or a visibly broken element, because variant has a workable default.

4. Two-way binding for form-like components

@Input() value = '';
@Output() valueChange = new EventEmitter<string>();

onInputChange(newValue: string): void {
  this.value = newValue;
  this.valueChange.emit(newValue);
}
<app-search-box [(value)]="searchTerm"></app-search-box>

Naming an @Output exactly {propertyName}Change alongside a matching @Input of the same base name is what unlocks Angular's banana-in-a-box [(value)] syntax automatically — this specific naming convention is required for the shorthand to work; any other name pair still works individually as separate [value] and (valueChange) bindings, just without the combined shorthand.

5. Keep the component's internal state actually internal

A genuinely reusable component shouldn't expose its internal implementation details as part of its public API — only @Input/@Output properties and, where relevant, projected content slots should be part of the contract a consumer relies on. Anything else (private helper methods, internal state properties) should stay unexported from the component's public surface, so the internal implementation is free to change later without breaking every place the component is used.

Topics: Developer Productivity