Build Reusable Angular Components Like a Pro: Inputs, Outputs, Content Projection, and a Real “Form-Friendly” Control

Build Reusable Angular Components Like a Pro: Inputs, Outputs, Content Projection, and a Real “Form-Friendly” Control

Angular components are easy to create—and surprisingly easy to create wrong. Many teams end up with components that are tightly coupled to one screen, impossible to reuse, and painful to test. In this hands-on guide, you’ll build a small set of reusable patterns you can apply immediately:

  • @Input + @Output done cleanly (with strong typing)
  • Content projection with <ng-content> (for flexible layouts)
  • A “form-friendly” custom component using ControlValueAccessor
  • Practical tips to keep components predictable and maintainable

All examples work in a standard Angular project (Angular 15+ recommended, but the patterns are older and stable).

1) Start with a Reusable “Alert Banner” Component

This component displays a message, supports variants (info/success/warn/error), and emits an event when the user dismisses it.

// alert-banner.component.ts import { Component, EventEmitter, Input, Output } from '@angular/core'; export type AlertVariant = 'info' | 'success' | 'warn' | 'error'; @Component({ selector: 'app-alert-banner', templateUrl: './alert-banner.component.html', styleUrls: ['./alert-banner.component.css'], }) export class AlertBannerComponent { @Input({ required: true }) message!: string; @Input() variant: AlertVariant = 'info'; @Input() dismissible = true; @Output() dismissed = new EventEmitter<void>(); onDismiss(): void { this.dismissed.emit(); } get icon(): string { switch (this.variant) { case 'success': return '✅'; case 'warn': return '⚠️'; case 'error': return '❌'; default: return 'ℹ️'; } } } 
<!-- alert-banner.component.html --> <div class="alert" [attr.data-variant]="variant" role="status"> <span class="icon" aria-hidden="true">{{ icon }}</span> <span class="message">{{ message }}</span> <button *ngIf="dismissible" type="button" class="dismiss" (click)="onDismiss()" aria-label="Dismiss alert" > ✖ </button> </div> 
/* alert-banner.component.css */ .alert { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1rem; border-radius: 0.5rem; border: 1px solid #ddd; } .alert[data-variant="info"] { background: #f3f8ff; border-color: #b6d4fe; } .alert[data-variant="success"] { background: #f2fbf5; border-color: #b7ebc6; } .alert[data-variant="warn"] { background: #fff8e6; border-color: #ffe08a; } .alert[data-variant="error"] { background: #fff3f3; border-color: #ffb3b3; } .dismiss { margin-left: auto; border: none; background: transparent; cursor: pointer; font-size: 1rem; } 

Use it like this:

<app-alert-banner [message]="'Profile saved successfully!'" variant="success" (dismissed)="showSaved = false" *ngIf="showSaved" ></app-alert-banner> 

Why this scales: It’s stateless from the outside. Inputs define what it shows, outputs describe what happened. The parent owns state (showSaved), which keeps behavior predictable.

2) Make It Flexible with Content Projection

Sometimes you want bold text, links, or custom markup inside the component. Instead of stuffing everything into a string input, use <ng-content>.

Let’s create a simple Card component with a header, body, and footer slots.

// card.component.ts import { Component, Input } from '@angular/core'; @Component({ selector: 'app-card', templateUrl: './card.component.html', styleUrls: ['./card.component.css'], }) export class CardComponent { @Input() title = ''; } 
<!-- card.component.html --> <section class="card"> <header class="card__header" *ngIf="title"> <h3 class="card__title">{{ title }}</h3> </header> <div class="card__body"> <ng-content></ng-content> </div> <footer class="card__footer"> <ng-content select="[cardFooter]"></ng-content> </footer> </section> 
/* card.component.css */ .card { border: 1px solid #e5e5e5; border-radius: 0.75rem; padding: 1rem; } .card__header { margin-bottom: 0.75rem; } .card__title { margin: 0; font-size: 1.1rem; } .card__footer { margin-top: 1rem; display: flex; justify-content: flex-end; } 

Use it like this:

<app-card title="Billing"> <p>Your next invoice is due on <strong>April 1</strong>.</p> <div cardFooter> <button type="button">Update card</button> </div> </app-card> 
  • <ng-content> without select projects everything.
  • select="[cardFooter]" creates a named “slot” using an attribute selector.

This is how you build components that feel like “layout primitives” instead of one-off UI chunks.

3) Build a Real Reusable Input: A Debounced Search Box

Search fields are everywhere. Let’s create <app-search-box> that emits debounced search terms, supports a placeholder, and has a clear button.

// search-box.component.ts import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { Subject, Subscription } from 'rxjs'; import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators'; @Component({ selector: 'app-search-box', templateUrl: './search-box.component.html', styleUrls: ['./search-box.component.css'], }) export class SearchBoxComponent implements OnInit, OnDestroy { @Input() placeholder = 'Search...'; @Input() debounceMs = 250; @Output() termChange = new EventEmitter<string>(); term = ''; private input$ = new Subject<string>(); private sub?: Subscription; ngOnInit(): void { this.sub = this.input$ .pipe( map(v => v.trim()), debounceTime(this.debounceMs), distinctUntilChanged() ) .subscribe(v => this.termChange.emit(v)); } ngOnDestroy(): void { this.sub?.unsubscribe(); } onInput(value: string): void { this.term = value; this.input$.next(value); } clear(): void { this.onInput(''); // Emits "" after debounce; if you want immediate clear, emit directly too: this.termChange.emit(''); } } 
<!-- search-box.component.html --> <div class="search"> <input type="search" [value]="term" (input)="onInput(($event.target as HTMLInputElement).value)" [placeholder]="placeholder" aria-label="Search" /> <button type="button" class="clear" (click)="clear()" [disabled]="!term" aria-label="Clear search" > Clear </button> </div> 
/* search-box.component.css */ .search { display: flex; gap: 0.5rem; align-items: center; } .clear { padding: 0.4rem 0.6rem; } 

Use it:

<app-search-box placeholder="Search users" [debounceMs]="300" (termChange)="loadUsers($event)" ></app-search-box> 

This component stays reusable because it doesn’t fetch data itself. It only emits a term; the parent decides what to do.

4) Make Custom Components Work with Angular Forms (ControlValueAccessor)

This is a big level-up: building components that behave like native inputs (<input>, <select>) in reactive forms. Once you implement ControlValueAccessor, your component can be used with formControlName.

We’ll build a simple <app-rating> component (1–5 stars) that supports:

  • Reactive forms integration
  • Disabled state
  • Touch tracking (so validation messages behave correctly)
// rating.component.ts import { Component, forwardRef, Input } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; @Component({ selector: 'app-rating', templateUrl: './rating.component.html', styleUrls: ['./rating.component.css'], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RatingComponent), multi: true, }, ], }) export class RatingComponent implements ControlValueAccessor { @Input() max = 5; value = 0; disabled = false; private onChange: (v: number) => void = () => {}; private onTouched: () => void = () => {}; writeValue(v: number | null): void { this.value = typeof v === 'number' ? v : 0; } registerOnChange(fn: (v: number) => void): void { this.onChange = fn; } registerOnTouched(fn: () => void): void { this.onTouched = fn; } setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; } setRating(v: number): void { if (this.disabled) return; this.value = v; this.onChange(v); this.onTouched(); } trackByIndex(i: number): number { return i; } } 
<!-- rating.component.html --> <div class="rating" role="radiogroup" [attr.aria-disabled]="disabled"> <button *ngFor="let _ of [].constructor(max); let i = index; trackBy: trackByIndex" type="button" class="star" [class.star--active]="i + 1 <= value" [disabled]="disabled" (click)="setRating(i + 1)" (blur)="onTouched()" [attr.aria-label]="'Rate ' + (i + 1) + ' out of ' + max" > ★ </button> </div> 
/* rating.component.css */ .rating { display: inline-flex; gap: 0.25rem; } .star { font-size: 1.25rem; background: transparent; border: 1px solid #ddd; border-radius: 0.4rem; cursor: pointer; } .star--active { border-color: #999; } .star:disabled { cursor: not-allowed; opacity: 0.6; } 

Now use it in a reactive form:

// feedback.component.ts import { Component } from '@angular/core'; import { FormBuilder, Validators } from '@angular/forms'; @Component({ selector: 'app-feedback', templateUrl: './feedback.component.html', }) export class FeedbackComponent { form = this.fb.group({ comment: ['', [Validators.required, Validators.minLength(10)]], rating: [3, [Validators.required]], }); constructor(private fb: FormBuilder) {} submit(): void { if (this.form.invalid) { this.form.markAllAsTouched(); return; } console.log('Submitted:', this.form.value); } } 
<!-- feedback.component.html --> <form [formGroup]="form" (ngSubmit)="submit()"> <label> Comment <textarea formControlName="comment"></textarea> </label> <div> <span>Rating</span> <app-rating formControlName="rating" [max]="5"></app-rating> </div> <p *ngIf="form.controls.comment.touched && form.controls.comment.invalid"> Comment must be at least 10 characters. </p> <button type="submit">Send</button> </form> 

Result: <app-rating> now behaves like a real form control—validation, disabled state, and touched/dirty tracking included.

5) Practical Rules for Components That Don’t Rot

  • Keep components “dumb” by default: inputs in, outputs out. Fetch data in containers/pages.
  • Avoid mixing concerns: a component should not both “render UI” and “own business rules” unless it’s truly a feature component.
  • Prefer explicit APIs: use typed @Input() and @Output() over reaching into services from everywhere.
  • Design for composition: content projection lets you build flexible UI without a dozen specialized variants.
  • If it’s an input, consider ControlValueAccessor: forms integration pays off quickly on real apps.

Wrap-Up

If you adopt just these patterns, you’ll notice an immediate difference: components become easier to reuse, easier to test, and easier to reason about. Start small—convert one “messy” UI element into a reusable component with a clear API. Then layer in content projection and forms integration where it fits.

Next step idea: extend <app-search-box> with an optional @Input() loading to show a spinner, or enhance <app-rating> to support keyboard navigation (arrow keys) for accessibility.


Leave a Reply

Your email address will not be published. Required fields are marked *