Practical Angular Components: Build a Reusable “Smart + Dumb” Feature with Standalone Components, Signals, and OnPush
Angular components are where most app logic lives—but it’s easy to end up with “god components” that fetch data, format it, handle UI state, and render everything in one file. In this hands-on guide, you’ll build a small feature the way many teams do it in real projects: a smart (container) component that handles state + data, and a dumb (presentational) component that focuses on UI.
You’ll also use two modern Angular patterns that make components faster and easier to reason about:
- Standalone components (no NgModules for this feature)
- Signals for local state + derived values
- ChangeDetectionStrategy.OnPush to reduce unnecessary re-renders
The example feature: a UserSearch page with a search box, loading/error states, and a reusable results list.
What You’ll Build
- A presentational component:
<user-results>that renders a list + emits selection events - A container component:
<user-search-page>that manages query, fetching, loading, errors - A lightweight service:
UserApithat calls a REST endpoint
This structure keeps UI components reusable and makes testing easier: you can test the container logic separately from the rendering.
1) Define a Simple User Model
Create a model file like user.model.ts:
export interface User { id: number; name: string; email: string; }
2) Build a Small API Service (HttpClient)
Create user-api.service.ts. This service fetches users from an endpoint like /api/users?q=alice. (In a real app you might add auth headers, retries, and caching—keep it minimal here.)
import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { User } from './user.model'; @Injectable({ providedIn: 'root' }) export class UserApi { constructor(private http: HttpClient) {} searchUsers(query: string): Observable<User[]> { const params = new HttpParams().set('q', query.trim()); return this.http.get<User[]>('/api/users', { params }); } }
Tip: Keep the service API “dumb” too. Don’t store UI state here. Return an Observable and let the component decide what to do.
3) Create the Presentational Component (Dumb UI)
This component should:
- Receive data via
@Input - Render it
- Emit events via
@Output - Not fetch data, not own app state
Create user-results.component.ts as a standalone component with OnPush.
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { CommonModule } from '@angular/common'; import { User } from './user.model'; @Component({ selector: 'user-results', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` <ul *ngIf="users?.length; else empty"> <li *ngFor="let u of users; trackBy: trackById"> <button type="button" (click)="select.emit(u)"> <strong>{{ u.name }}</strong> — <code>{{ u.email }}</code> </button> </li> </ul> <ng-template #empty> <p>No results.</p> </ng-template> ` }) export class UserResultsComponent { @Input() users: User[] | null = null; @Output() select = new EventEmitter<User>(); trackById(_: number, u: User) { return u.id; } }
Why OnPush here? This component re-renders only when its inputs change by reference (or an event happens inside it). That’s great for lists and reusable UI.
4) Container Component with Signals (Smart Component)
The container manages:
- Current query
- Loading + error state
- Results list
- Debounced fetching (so you don’t call the API on every keystroke instantly)
Create user-search-page.component.ts:
import { ChangeDetectionStrategy, Component, computed, effect, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { debounceTime, distinctUntilChanged, filter, switchMap, catchError, of, startWith, tap } from 'rxjs'; import { UserApi } from './user-api.service'; import { User } from './user.model'; import { UserResultsComponent } from './user-results.component'; @Component({ selector: 'user-search-page', standalone: true, imports: [CommonModule, ReactiveFormsModule, UserResultsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` <h3>User Search</h3> <label> Search: <input [formControl]="queryCtrl" placeholder="Type a name…" /> </label> <p *ngIf="loading()">Loading…</p> <p *ngIf="error()"><strong>Error:</strong> {{ error() }}</p> <p *ngIf="hasQuery() && !loading() && !error()"> Found {{ resultsCount() }} result(s) </p> <user-results [users]="users()" (select)="onSelect($event)" ></user-results> ` }) export class UserSearchPageComponent { queryCtrl = new FormControl('', { nonNullable: true }); // Signals for state users = signal<User[] | null>(null); loading = signal(false); error = signal<string | null>(null); // Derived state hasQuery = computed(() => this.queryCtrl.value.trim().length > 0); resultsCount = computed(() => this.users()?.length ?? 0); constructor(private api: UserApi) { // Wire FormControl (Observable) to signals this.queryCtrl.valueChanges.pipe( startWith(this.queryCtrl.value), debounceTime(250), distinctUntilChanged(), tap(() => { this.error.set(null); this.loading.set(true); }), mapQuery => mapQuery, // placeholder for readability filter(q => q.trim().length >= 2), switchMap(q => this.api.searchUsers(q).pipe( catchError(err => { this.error.set(err?.message ?? 'Request failed'); return of([] as User[]); }) ) ), tap(() => this.loading.set(false)) ).subscribe(users => { this.users.set(users); }); // If user clears input, reset state this.queryCtrl.valueChanges.pipe( startWith(this.queryCtrl.value), debounceTime(0), distinctUntilChanged(), filter(q => q.trim().length === 0) ).subscribe(() => { this.users.set(null); this.loading.set(false); this.error.set(null); }); } onSelect(user: User) { // In real apps you might route or open a details panel. alert(`Selected: ${user.name} (${user.email})`); } }
Important: For brevity we used two subscriptions. In production, you can consolidate logic into one stream (or use a helper like takeUntilDestroyed())—but the key idea remains: Observable-based inputs (forms) can drive signal-based state cleanly.
Debounce and minimum query length are practical “junior-to-mid” upgrades that immediately reduce API spam.
5) Hook It into Routing (Standalone Route)
If you’re using standalone routing, you can define a route that points directly to the container component.
import { Routes } from '@angular/router'; import { UserSearchPageComponent } from './user-search-page.component'; export const routes: Routes = [ { path: 'users/search', component: UserSearchPageComponent }, ];
6) A Tiny Backend Stub (Optional)
If you want to test without a real backend, you can mock the endpoint using Angular’s in-memory web API or a simple Node server. Here’s a minimal Node/Express example for /api/users:
import express from 'express'; const app = express(); const USERS = [ { id: 1, name: 'Alice Johnson', email: '[email protected]' }, { id: 2, name: 'Bob Smith', email: '[email protected]' }, { id: 3, name: 'Alicia Stone', email: '[email protected]' }, ]; app.get('/api/users', (req, res) => { const q = String(req.query.q ?? '').toLowerCase(); const results = USERS.filter(u => u.name.toLowerCase().includes(q)); // simulate latency setTimeout(() => res.json(results), 300); }); app.listen(3000, () => console.log('API on http://localhost:3000'));
Point your Angular dev server proxy to this API if needed (e.g., via proxy.conf.json) so /api routes work locally.
7) Practical Component Rules You Can Reuse
- Presentational components accept inputs and emit outputs. No HTTP calls, no routing, no “global state.”
- Container components handle data fetching, orchestration, and pass data down.
- Use OnPush almost everywhere; it nudges you toward predictable data flow.
- Keep state in a few obvious places: signals like
loading,error,users. - Prefer derived values via
computed()(likeresultsCount) instead of recalculating in the template. - For lists, always use a trackBy function to avoid re-rendering every row.
8) Where to Go Next
Once this pattern feels natural, you can level it up with:
- A
UserStoreservice using signals for shared state across routes - Better error handling (HTTP status mapping, user-friendly messages)
- Unit tests:
- Test
UserResultsComponentas a pure UI component - Test
UserSearchPageComponentwith mockedUserApi
- Test
- Virtual scrolling for large result sets
If you keep the “smart + dumb” split and drive UI from explicit state (signals), your Angular components will stay maintainable as the app grows—without turning every feature into a refactor later.
Leave a Reply