Modern Angular Components, the Practical Way: Standalone + Signals + Built-in Control Flow
If you’re building Angular apps in 2024+ projects, you’ll increasingly see standalone components as the default, plus a newer reactivity model called signals, and a more ergonomic template syntax (@if, @for, @switch). These features make it easier to ship small, reusable components without drowning in module plumbing, and they help you manage UI state with fewer subscriptions and less change-detection guesswork. :contentReference[oaicite:0]{index=0}
This hands-on guide builds a tiny “Tasks” feature using:
- Standalone components (no feature modules required)
- Signals for local state + derived state
- Built-in control flow (
@if,@for) - A clean “container + presentational component” structure
1) Create a new project (standalone by default)
Generate a new app:
ng new tasks-app cd tasks-app ng serve
In newer Angular CLI setups, components are standalone by default, so you’ll typically wire features by importing components directly rather than declaring them in NgModules. :contentReference[oaicite:1]{index=1}
2) Define a small domain model
Create a simple type for tasks:
// src/app/tasks/task.model.ts export type Task = { id: string; title: string; done: boolean; };
3) Build a presentational component (pure UI)
This component only renders tasks and emits user actions. It doesn’t fetch data or decide filtering rules—that’s the container’s job.
// src/app/tasks/task-list.component.ts import { Component, input, output } from '@angular/core'; import { Task } from './task.model'; @Component({ selector: 'app-task-list', standalone: true, template: ` <section class="panel"> <h3>Tasks</h3> @if (tasks().length === 0) { <p class="muted">No tasks yet. Add one!</p> } @else { <ul class="list"> @for (t of tasks(); track t.id) { <li class="row"> <label> <input type="checkbox" [checked]="t.done" (change)="toggle.emit(t.id)" /> <span [class.done]="t.done">{{ t.title }}</span> </label> <button type="button" (click)="remove.emit(t.id)">Delete</button> </li> } </ul> } </section> `, styles: [` .panel { padding: 12px; border: 1px solid #ddd; border-radius: 10px; } .list { list-style: none; padding: 0; margin: 0; } .row { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid #eee; } .row:last-child { border-bottom: none; } .muted { opacity: 0.7; } .done { text-decoration: line-through; opacity: 0.7; } `] }) export class TaskListComponent { // signal-based inputs (Angular reads them as functions: tasks()) tasks = input<Task[]>([]); toggle = output<string>(); remove = output<string>(); }
Notice the template uses built-in control flow @if and @for. This syntax is available from Angular v17, and Angular provides migrations from *ngIf/*ngFor. :contentReference[oaicite:2]{index=2}
4) Build a container component (state + orchestration)
The container holds state in signals, computes derived values, and passes data down to the list. This keeps your UI components reusable and easy to test.
// src/app/tasks/tasks-page.component.ts import { Component, computed, signal } from '@angular/core'; import { Task } from './task.model'; import { TaskListComponent } from './task-list.component'; type Filter = 'all' | 'open' | 'done'; function uid() { return crypto.randomUUID?.() ?? Math.random().toString(16).slice(2); } @Component({ selector: 'app-tasks-page', standalone: true, imports: [TaskListComponent], template: ` <main class="wrap"> <h2>Tasks (Signals + Standalone)</h2> <form class="bar" (submit)="addTask($event)"> <input name="title" placeholder="Add a task..." autocomplete="off" /> <button type="submit">Add</button> <select [value]="filter()" (change)="setFilter($any($event.target).value)"> <option value="all">All</option> <option value="open">Open</option> <option value="done">Done</option> </select> </form> <p class="muted"> Showing {{ visibleTasks().length }} / {{ tasks().length }} — Open: {{ openCount() }} </p> <app-task-list [tasks]="visibleTasks()" (toggle)="toggleDone($event)" (remove)="removeTask($event)" /> </main> `, styles: [` .wrap { max-width: 720px; margin: 24px auto; padding: 0 12px; font-family: system-ui, sans-serif; } .bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 8px; } input { flex: 1; min-width: 220px; padding: 8px 10px; border-radius: 8px; border: 1px solid #ccc; } button, select { padding: 8px 10px; border-radius: 8px; border: 1px solid #ccc; background: white; } .muted { opacity: 0.75; } `] }) export class TasksPageComponent { // Source-of-truth state readonly tasks = signal<Task[]>([ { id: uid(), title: 'Ship the feature', done: false }, { id: uid(), title: 'Write a test', done: false }, { id: uid(), title: 'Refactor', done: true }, ]); readonly filter = signal<Filter>('all'); // Derived state (computed signals) readonly visibleTasks = computed(() => { const f = this.filter(); const all = this.tasks(); if (f === 'open') return all.filter(t => !t.done); if (f === 'done') return all.filter(t => t.done); return all; }); readonly openCount = computed(() => this.tasks().reduce((n, t) => n + (t.done ? 0 : 1), 0)); setFilter(value: Filter) { this.filter.set(value); } addTask(evt: SubmitEvent) { evt.preventDefault(); const form = evt.target as HTMLFormElement; const title = (new FormData(form).get('title') ?? '').toString().trim(); if (!title) return; this.tasks.update(curr => [{ id: uid(), title, done: false }, ...curr]); form.reset(); } toggleDone(id: string) { this.tasks.update(curr => curr.map(t => (t.id === id ? { ...t, done: !t.done } : t)) ); } removeTask(id: string) { this.tasks.update(curr => curr.filter(t => t.id !== id)); } }
Why this pattern works well: signals give you simple synchronous reads (tasks()) and updates (tasks.update(...)), while computed gives you memoized derived values without manually wiring RxJS pipelines. :contentReference[oaicite:3]{index=3}
5) Wire the page into the app
In your root component, import the page component directly:
// src/app/app.component.ts import { Component } from '@angular/core'; import { TasksPageComponent } from './tasks/tasks-page.component'; @Component({ selector: 'app-root', standalone: true, imports: [TasksPageComponent], template: `<app-tasks-page />`, }) export class AppComponent {}
6) Practical component guidelines (the stuff that prevents messes)
- Keep presentational components dumb. They should take data via
input()and emit events viaoutput(). No filtering rules, no HTTP calls. - Use
computedfor derived state. If you can compute it from other signals, don’t store it separately (avoids “state drift”). - Prefer immutable updates. Returning new arrays/objects makes changes obvious and predictable.
- Use
trackin@for. Stable keys (likeid) prevent unnecessary DOM churn in lists. - Don’t be dogmatic. RxJS is still great for streams (websockets, typeahead, complex async). Signals shine for local UI state and derived values.
7) A tiny “works in real life” upgrade: optimistic async toggle
Let’s say toggling a task should call an API. You can optimistically update UI first, then revert on failure.
// src/app/tasks/fake-api.ts export async function saveTaskDone(id: string, done: boolean): Promise<void> { // Simulate latency + occasional failure await new Promise(r => setTimeout(r, 300)); if (Math.random() < 0.2) throw new Error('Random API failure'); }
// Patch in TasksPageComponent.toggleDone import { saveTaskDone } from './fake-api'; async toggleDone(id: string) { const before = this.tasks(); const next = before.map(t => (t.id === id ? { ...t, done: !t.done } : t)); this.tasks.set(next); const changed = next.find(t => t.id === id)!; try { await saveTaskDone(changed.id, changed.done); } catch { // revert on failure this.tasks.set(before); alert('Could not save. Reverted the change.'); } }
This pattern is easy to reason about because you always keep one source of truth (tasks) and your template reads from it directly.
Wrap-up
You now have a clean, reusable component setup that a junior/mid team can extend safely:
- A presentational list component that’s portable across pages
- A container component that owns signals and derived state
- Built-in control flow that keeps templates readable and list rendering efficient :contentReference[oaicite:4]{index=4}
Next steps to practice: add an “edit task” inline form, persist tasks to localStorage, and introduce routing—keeping the same container/presentational split as your app grows.
::contentReference[oaicite:5]{index=5}
Leave a Reply