Build Reusable Angular Components: Inputs/Outputs, Smart Defaults, and a Production-Ready “Confirm Dialog”

Build Reusable Angular Components: Inputs/Outputs, Smart Defaults, and a Production-Ready “Confirm Dialog”

Angular components are easy to create and surprisingly easy to misuse. Junior teams often end up with “one-off” components wired tightly to one screen, duplicated styles, and business logic sprinkled in templates. In this hands-on guide, you’ll build a reusable <app-confirm-dialog> component and learn patterns you can apply to any UI piece: clean @Input()/@Output() APIs, sensible defaults, strongly-typed events, and a tiny service to open dialogs from anywhere.

The examples assume Angular 16+ (works great in Angular 17+ too), and use standalone components to keep things modern and lightweight.

What We’re Building

  • A reusable ConfirmDialogComponent that can be dropped into any page.
  • A clear public API: inputs for text/labels + output events for user actions.
  • A ConfirmService that lets you open the dialog with a simple await-like promise flow.
  • Accessible markup (keyboard + ARIA basics) and practical defaults.

1) Start With a Clean Component API

Before writing HTML, decide how other developers will use your component. A “confirm” dialog typically needs:

  • Title + message
  • Confirm and cancel labels
  • A destructive mode (for delete actions)
  • Events for confirm/cancel/close
  • A boolean to show/hide

Create a standalone component:

ng generate component shared/confirm-dialog --standalone

Now implement it with a small, explicit API.

import { Component, EventEmitter, HostListener, Input, Output } from '@angular/core'; import { CommonModule } from '@angular/common'; export type ConfirmResult = 'confirm' | 'cancel'; @Component({ selector: 'app-confirm-dialog', standalone: true, imports: [CommonModule], template: ` <div class="backdrop" *ngIf="open" (click)="onBackdropClick()"></div> <div class="dialog" *ngIf="open" role="dialog" aria-modal="true" [attr.aria-label]="title" tabindex="-1" (click)="$event.stopPropagation()" > <h3 class="title">{{ title }}</h3> <p class="message">{{ message }}</p> <div class="actions"> <button type="button" class="btn" (click)="cancel()">{{ cancelText }}</button> <button type="button" class="btn" [class.destructive]="destructive" (click)="confirm()" > {{ confirmText }} </button> </div> </div> `, styles: [` .backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); } .dialog { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: min(520px, calc(100vw - 32px)); background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); outline: none; } .title { margin: 0 0 8px; font-size: 18px; } .message { margin: 0 0 16px; color: #444; } .actions { display: flex; justify-content: flex-end; gap: 8px; } .btn { border: 1px solid #ddd; background: #f7f7f7; padding: 8px 12px; border-radius: 10px; cursor: pointer; } .btn:hover { background: #efefef; } .destructive { border-color: #e33; background: #ffecec; } .destructive:hover { background: #ffdede; } `], }) export class ConfirmDialogComponent { @Input() open = false; @Input() title = 'Confirm'; @Input() message = 'Are you sure?'; @Input() confirmText = 'Confirm'; @Input() cancelText = 'Cancel'; @Input() destructive = false; @Output() closed = new EventEmitter<ConfirmResult>(); // ESC closes dialog (common UX) @HostListener('document:keydown.escape') onEscape() { if (this.open) this.cancel(); } onBackdropClick() { // Clicking outside cancels by default (you can make this configurable) this.cancel(); } confirm() { this.closed.emit('confirm'); } cancel() { this.closed.emit('cancel'); } }

Why this is “practical”: the component has clear defaults, is strongly typed (ConfirmResult), and doesn’t know anything about deleting users or saving posts. That makes it reusable.

2) Use the Component Directly in a Page

Here’s a typical “Delete user” page using the dialog. Note how the page owns the business logic; the dialog only emits an intent.

import { Component } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ConfirmDialogComponent, ConfirmResult } from '../shared/confirm-dialog/confirm-dialog.component'; @Component({ standalone: true, imports: [CommonModule, ConfirmDialogComponent], template: ` <button (click)="openDeleteDialog()">Delete account</button> <app-confirm-dialog [open]="dialogOpen" title="Delete account" message="This action cannot be undone." confirmText="Yes, delete" cancelText="No, keep it" [destructive]="true" (closed)="handleDialog($event)" ></app-confirm-dialog> ` }) export class AccountPageComponent { dialogOpen = false; openDeleteDialog() { this.dialogOpen = true; } async handleDialog(result: ConfirmResult) { this.dialogOpen = false; if (result === 'confirm') { // Call API, show toast, navigate, etc. console.log('Deleting...'); // await this.api.deleteAccount(); } } }

This is already useful. But on a bigger app, manually placing the dialog on every page gets old. Let’s add a tiny service so you can “ask for confirmation” from anywhere.

3) Add a Confirm Service (Promise-Based Flow)

We’ll make a service that exposes a confirm() method returning a Promise<boolean>. Under the hood, it uses a shared state (a simple reactive store) that a single global dialog component listens to.

Create a service:

import { Injectable, signal } from '@angular/core'; export type ConfirmOptions = { title?: string; message?: string; confirmText?: string; cancelText?: string; destructive?: boolean; }; type ConfirmState = | { open: false } | { open: true; options: Required<ConfirmOptions>; resolve: (v: boolean) => void }; @Injectable({ providedIn: 'root' }) export class ConfirmService { private readonly defaults: Required<ConfirmOptions> = { title: 'Confirm', message: 'Are you sure?', confirmText: 'Confirm', cancelText: 'Cancel', destructive: false, }; state = signal<ConfirmState>({ open: false }); confirm(options: ConfirmOptions = {}): Promise<boolean> { return new Promise<boolean>((resolve) => { this.state.set({ open: true, options: { ...this.defaults, ...options }, resolve, }); }); } close(result: boolean) { const current = this.state(); if (current.open) current.resolve(result); this.state.set({ open: false }); } }

Now create a single “host” component that you render once (for example in AppComponent).

import { Component, computed, effect } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ConfirmDialogComponent } from './confirm-dialog.component'; import { ConfirmService } from './confirm.service'; @Component({ selector: 'app-confirm-host', standalone: true, imports: [CommonModule, ConfirmDialogComponent], template: ` <app-confirm-dialog [open]="open()" [title]="opts().title" [message]="opts().message" [confirmText]="opts().confirmText" [cancelText]="opts().cancelText" [destructive]="opts().destructive" (closed)="onClosed($event)" ></app-confirm-dialog> ` }) export class ConfirmHostComponent { open = computed(() => this.confirm.state().open); opts = computed(() => { const s = this.confirm.state(); return s.open ? s.options : { title: 'Confirm', message: 'Are you sure?', confirmText: 'Confirm', cancelText: 'Cancel', destructive: false }; }); constructor(private confirm: ConfirmService) {} onClosed(result: 'confirm' | 'cancel') { this.confirm.close(result === 'confirm'); } }

Finally, render the host once in your root component template:

<router-outlet></router-outlet> <app-confirm-host></app-confirm-host>

4) Use It Anywhere With One Line

Now any component can do:

import { Component } from '@angular/core'; import { ConfirmService } from '../shared/confirm-dialog/confirm.service'; @Component({ standalone: true, template: ` <button (click)="dangerousAction()">Reset all settings</button> ` }) export class SettingsPageComponent { constructor(private confirm: ConfirmService) {} async dangerousAction() { const ok = await this.confirm.confirm({ title: 'Reset settings', message: 'This will revert everything to defaults.', confirmText: 'Reset', cancelText: 'Cancel', destructive: true }); if (!ok) return; // Proceed with action console.log('Resetting settings...'); } }

This pattern scales nicely: your UI remains consistent, and you don’t re-implement confirmation logic on every page.

5) Practical Tips to Keep Components Reusable

  • Keep business logic outside. Your component should not call APIs or know domain details. Emit events; let the parent decide.

  • Prefer typed outputs. Using EventEmitter<'confirm' | 'cancel'> prevents stringly-typed mistakes.

  • Have defaults that make sense. Juniors can drop the component in and get good UX without configuring everything.

  • Make behavior configurable only when needed. For example, backdrop-to-cancel is good UX for many apps. If you later need “force user choice,” add an optional @Input() closeOnBackdrop = true.

  • Think accessibility early. Use role="dialog", aria-modal="true", Escape-to-close, and reasonable focus behavior. (Full focus trapping is a bigger topic, but this is a solid baseline.)

6) A Quick Unit Test Example

Here’s a simple test to ensure the component emits the correct value when confirming. (This is intentionally minimal and should work with Angular’s testing setup.)

import { TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { ConfirmDialogComponent } from './confirm-dialog.component'; describe('ConfirmDialogComponent', () => { it('emits "confirm" when confirm button is clicked', () => { TestBed.configureTestingModule({ imports: [ConfirmDialogComponent], }); const fixture = TestBed.createComponent(ConfirmDialogComponent); const comp = fixture.componentInstance; comp.open = true; fixture.detectChanges(); let emitted: any = null; comp.closed.subscribe(v => (emitted = v)); const buttons = fixture.debugElement.queryAll(By.css('button')); const confirmBtn = buttons[1]; // second button confirmBtn.triggerEventHandler('click'); expect(emitted).toBe('confirm'); }); });

Wrap-Up

You now have a reusable Angular dialog component with:

  • Clear and typed @Input()/@Output() API
  • Sane defaults that make it easy to adopt
  • A lightweight service + host component so you can confirm actions from anywhere
  • Testable behavior and an accessible baseline

Next time you build a “widget” (table, pagination, toast, dropdown), apply the same approach: define a public API first, keep domain logic out, type your events, and consider a tiny service when the component becomes cross-cutting.


Leave a Reply

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