Angular Components in Practice: Stand-alone Components, Inputs/Outputs, and a Reusable “Smart Table”
Angular components are the building blocks of your UI, but many apps end up with components that are too big, too coupled to services, and hard to reuse. In this hands-on guide, you’ll build a reusable <app-smart-table> component using modern Angular patterns: standalone components, strongly-typed @Input()/@Output(), and clean separation between “container” and “presentational” components.
The goal: a table component you can drop into multiple screens, with sorting, simple filtering, and row actions—without re-writing the same template logic over and over.
What We’re Building
- A
SmartTableComponentthat receives rows + column definitions - Sorting by clicking column headers
- Client-side filtering via a search box
- Row actions (e.g., “Edit”, “Delete”) emitted as events
- A container component that fetches data and passes it down
1) Define the Types (Strong Typing Pays Off)
Create a small types file to keep your component API clean and predictable:
// smart-table.types.ts export type SortDirection = 'asc' | 'desc'; export interface ColumnDef<T> { key: keyof T; // property name on the row object label: string; // header label sortable?: boolean; // enable sorting format?: (value: any, row: T) => string; // optional formatter } export interface SortState<T> { key: keyof T; dir: SortDirection; } export interface RowAction<T> { type: string; // e.g. "edit", "delete" row: T; }
Notice ColumnDef<T> uses keyof T. This means if your rows are User, the column key must be a real User property—TypeScript will catch typos.
2) Build the Standalone Smart Table Component
This component is “presentational”: it doesn’t fetch data; it just renders what it’s given and emits events.
// smart-table.component.ts import { Component, computed, input, output, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ColumnDef, RowAction, SortState } from './smart-table.types'; @Component({ selector: 'app-smart-table', standalone: true, imports: [CommonModule], template: ` <div class="table-shell"> <div class="toolbar"> <input type="search" class="search" placeholder="Filter..." [value]="filterText()" (input)="filterText.set(($any($event.target)).value)" /> <span class="meta">{{ filteredRows().length }} results</span> </div> <table class="table"> <thead> <tr> <th *ngFor="let col of columns()" (click)="toggleSort(col)" [class.sortable]="col.sortable" > {{ col.label }} <span *ngIf="sort()?.key === col.key"> {{ sort()?.dir === 'asc' ? '▲' : '▼' }} </span> </th> <th *ngIf="showActions()">Actions</th> </tr> </thead> <tbody> <tr *ngFor="let row of sortedRows(); trackBy: trackByIndex"> <td *ngFor="let col of columns()"> {{ cellText(col, row) }} </td> <td *ngIf="showActions()"> <button type="button" (click)="emitAction('edit', row)">Edit</button> <button type="button" (click)="emitAction('delete', row)">Delete</button> </td> </tr> <tr *ngIf="sortedRows().length === 0"> <td [attr.colspan]="columns().length + (showActions() ? 1 : 0)"> No matching rows </td> </tr> </tbody> </table> </div> `, styles: [` .table-shell { border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; } .toolbar { display:flex; gap:12px; align-items:center; margin-bottom:10px; } .search { flex:1; padding:8px 10px; border:1px solid #d1d5db; border-radius:8px; } .meta { opacity:.75; font-size: 0.9rem; } .table { width:100%; border-collapse: collapse; } th, td { padding: 10px; border-top: 1px solid #f1f5f9; text-align:left; } th.sortable { cursor:pointer; user-select:none; } button { margin-right: 8px; } `] }) export class SmartTableComponent<T extends Record<string, any>> { // Inputs (Angular v16+ signals API) rows = input.required<T[]>(); columns = input.required<ColumnDef<T>[]>(); showActions = input<boolean>(true); // Output event for row actions action = output<RowAction<T>>(); // Internal state filterText = signal(''); sort = signal<SortState<T> | null>(null); filteredRows = computed(() => { const text = this.filterText().trim().toLowerCase(); if (!text) return this.rows(); // Simple “contains” filter across all column values const cols = this.columns(); return this.rows().filter(row => { return cols.some(c => String(row[c.key] ?? '') .toLowerCase() .includes(text)); }); }); sortedRows = computed(() => { const s = this.sort(); const data = [...this.filteredRows()]; if (!s) return data; data.sort((a, b) => { const av = a[s.key]; const bv = b[s.key]; // Basic comparison with null safety if (av == null && bv == null) return 0; if (av == null) return s.dir === 'asc' ? -1 : 1; if (bv == null) return s.dir === 'asc' ? 1 : -1; // Numbers sort numerically; otherwise string compare if (typeof av === 'number' && typeof bv === 'number') { return s.dir === 'asc' ? av - bv : bv - av; } const as = String(av).toLowerCase(); const bs = String(bv).toLowerCase(); if (as < bs) return s.dir === 'asc' ? -1 : 1; if (as > bs) return s.dir === 'asc' ? 1 : -1; return 0; }); return data; }); toggleSort(col: ColumnDef<T>) { if (!col.sortable) return; const current = this.sort(); if (!current || current.key !== col.key) { this.sort.set({ key: col.key, dir: 'asc' }); return; } // Toggle direction this.sort.set({ key: col.key, dir: current.dir === 'asc' ? 'desc' : 'asc' }); } cellText(col: ColumnDef<T>, row: T) { const raw = row[col.key]; return col.format ? col.format(raw, row) : String(raw ?? ''); } emitAction(type: string, row: T) { this.action.emit({ type, row }); } trackByIndex = (i: number) => i; }
What’s worth noting here:
standalone: truekeeps the component self-contained (no NgModule needed).- Inputs are typed and required:
input.required<T[]>(). - We use
computed()for derived UI state (filtered/sorted rows). This reduces bugs versus manually syncing state. - We emit structured actions like
{ type: 'delete', row }instead of passing raw clicks around.
3) Use It in a Container Component
Now let’s build a “container” component that owns data fetching and business logic. The table stays dumb and reusable.
// users-page.component.ts import { Component, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { SmartTableComponent } from './smart-table.component'; import { ColumnDef, RowAction } from './smart-table.types'; import { UsersService, User } from './users.service'; @Component({ selector: 'app-users-page', standalone: true, imports: [CommonModule, SmartTableComponent], template: ` <h3>Users</h3> <app-smart-table [rows]="users()" [columns]="columns" [showActions]="true" (action)="onAction($event)" ></app-smart-table> ` }) export class UsersPageComponent { private usersService = inject(UsersService); users = signal<User[]>([]); columns: ColumnDef<User>[] = [ { key: 'id', label: 'ID', sortable: true }, { key: 'name', label: 'Name', sortable: true }, { key: 'email', label: 'Email', sortable: true }, { key: 'createdAt', label: 'Created', sortable: true, format: (v) => new Date(v).toLocaleDateString() }, ]; async ngOnInit() { const data = await this.usersService.list(); this.users.set(data); } onAction(evt: RowAction<User>) { if (evt.type === 'edit') { // route to edit page, open modal, etc. console.log('Edit user', evt.row.id); } if (evt.type === 'delete') { // optimistic UI example: this.users.set(this.users().filter(u => u.id !== evt.row.id)); this.usersService.delete(evt.row.id).catch(() => { // rollback on failure this.ngOnInit(); }); } } }
4) A Minimal Service (Mock or Real HTTP)
Here’s a simple service you can swap for real HTTP later. If you use HttpClient, keep the table untouched—only the container changes.
// users.service.ts export interface User { id: number; name: string; email: string; createdAt: string; // ISO string } export class UsersService { private data: User[] = [ { id: 1, name: 'Ava', email: '[email protected]', createdAt: '2025-12-01T10:00:00Z' }, { id: 2, name: 'Noah', email: '[email protected]', createdAt: '2025-12-11T10:00:00Z' }, { id: 3, name: 'Mia', email: '[email protected]', createdAt: '2026-01-05T10:00:00Z' }, ]; async list(): Promise<User[]> { await this.sleep(150); return [...this.data]; } async delete(id: number): Promise<void> { await this.sleep(150); this.data = this.data.filter(u => u.id !== id); } private sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } }
5) Practical Tips to Keep Components Maintainable
-
Design component APIs like libraries. Your
SmartTableComponenttakesrowsandcolumnsand emitsaction. That’s it. No internal HTTP, no “sometimes it navigates”. -
Prefer “container + presentational”. Containers coordinate data/services; presentational components render and emit events. This reduces coupling and makes testing easier.
-
Keep formatting out of templates. Notice the
formatfunction on the column definition—this avoids deeply nested template logic. -
Be explicit about sorting rules. If you expect locale-aware sorting, you can swap string comparison for
Intl.Collator. If you expect stable sorting, keep a secondary key. -
Track items properly. For large lists, swap
trackByIndexwith a stable row ID function to reduce DOM churn.
6) Quick Extension Ideas
If you want to level this up without rewriting everything:
@Input()for custom action buttons (e.g., pass an array of action descriptors)- Pagination (client-side with
computed(), or server-side by emitting page events) - Column hiding/toggling (store visible column keys in a signal)
- Row selection with a
selectedIdssignal and a(selectionChange)output
Wrap-Up
You now have a clean, reusable Angular component that juniors can understand and mid-level devs can extend. The key is the discipline of a small, typed component API: inputs for data, outputs for events, and a container component for side effects. Build a few components like this and your Angular UI stops feeling like a tangled web—and starts behaving like a system.
Leave a Reply