1) Create a fresh app
# Angular 17+ (works on 18/20 too)
npm create @angular@latest task-tracker -- --standalone
cd task-tracker
npm i
2) Wire up main.ts
// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent).catch(err => console.error(err));
3) Task model
// src/app/task.model.ts
export interface Task {
id: string;
title: string;
done: boolean;
createdAt: number;
}
4) Signal store (core of the app)
- Holds state in signals
- Derives computed views (filtered list, counts)
- Uses an effect to persist to localStorage
// src/app/task.store.ts
import { Injectable, effect, signal, computed } from '@angular/core';
import { Task } from './task.model';
type Filter = 'all' | 'open' | 'done';
@Injectable({ providedIn: 'root' })
export class TaskStore {
// raw state
private _tasks = signal<Task[]>([]);
readonly tasks = this._tasks.asReadonly();
filter = signal<Filter>('all');
// derived state
readonly openCount = computed(
() => this._tasks().filter(t => !t.done).length
);
readonly filtered = computed(() => {
const f = this.filter();
const list = this._tasks();
if (f === 'open') return list.filter(t => !t.done);
if (f === 'done') return list.filter(t => t.done);
return list;
});
constructor() {
// hydrate from localStorage
const raw = localStorage.getItem('tasks');
if (raw) this._tasks.set(JSON.parse(raw));
// persist whenever tasks change
effect(() => {
localStorage.setItem('tasks', JSON.stringify(this._tasks()));
});
}
add(title: string) {
const trimmed = title.trim();
if (!trimmed) return;
this._tasks.update(list => [
...list,
{ id: crypto.randomUUID(), title: trimmed, done: false, createdAt: Date.now() }
]);
}
toggle(id: string) {
this._tasks.update(list =>
list.map(t => (t.id === id ? { ...t, done: !t.done } : t))
);
}
remove(id: string) {
this._tasks.update(list => list.filter(t => t.id !== id));
}
clearDone() {
this._tasks.update(list => list.filter(t => !t.done));
}
setFilter(f: Filter) {
this.filter.set(f);
}
}
5) Root component (standalone + signals + control flow)
- Uses
@for(Angular’s modern template loop) - No Zone.js tricks needed; signals keep updates precise
- OnPush is natural with signals
// src/app/app.component.ts
import { ChangeDetectionStrategy, Component, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, NgForm } from '@angular/forms';
import { TaskStore } from './task.store';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [`
:host { display:block; max-width:720px; margin:32px auto; font: 14px/1.4 system-ui, Arial; }
h1 { margin: 0 0 16px; }
form { display:flex; gap:8px; margin: 16px 0; }
input[type=text]{ flex:1; padding:10px; border:1px solid #ddd; border-radius:8px; }
button { padding:10px 14px; border:0; border-radius:8px; cursor:pointer; }
.list { margin-top:12px; border:1px solid #eee; border-radius:12px; overflow:hidden; }
.row { display:flex; align-items:center; gap:12px; padding:10px 12px; border-top:1px solid #f3f3f3; }
.row:first-child { border-top:0; }
.title { flex:1; }
.done .title { text-decoration: line-through; color:#888; }
nav { display:flex; gap:8px; margin: 8px 0 16px; }
nav button { background:#f4f4f4; }
nav button.active { background:#222; color:#fff; }
.meta { color:#666; font-size:12px; margin-top:8px; }
`],
template: `
<h1>Task Tracker (Signals)</h1>
<form #f="ngForm" (ngSubmit)="create()">
<input #titleInput="ngModel" name="title" [(ngModel)]="title" type="text" placeholder="Add a task…" required minlength="2" />
<button type="submit">Add</button>
</form>
<nav>
<button (click)="store.setFilter('all')" [class.active]="store.filter() === 'all'">All</button>
<button (click)="store.setFilter('open')" [class.active]="store.filter() === 'open'">Open</button>
<button (click)="store.setFilter('done')" [class.active]="store.filter() === 'done'">Done</button>
</nav>
<div class="list" *ngIf="store.filtered().length; else empty">
@for (task of store.filtered(); track task.id) {
<div class="row" [class.done]="task.done">
<input type="checkbox" [checked]="task.done" (change)="store.toggle(task.id)" />
<div class="title">{{ task.title }}</div>
<button (click)="store.remove(task.id)" title="Remove">✕</button>
</div>
}
</div>
<ng-template #empty>
<div class="meta">No tasks yet — add one above.</div>
</ng-template>
<div class="meta">
{{ store.openCount() }} open /
{{ store.tasks().length }} total
<button *ngIf="store.tasks().some(t => t.done)" (click)="store.clearDone()">Clear done</button>
</div>
`
})
export class AppComponent {
title = '';
constructor(public store: TaskStore) {}
create() {
this.store.add(this.title);
this.title = '';
}
}
6) Run it
npm start
# or
ng serve -o
How this demonstrates the Signals concept
signal()holds the source of truth (_tasks,filter).computed()creates derived views (openCount,filtered) that update only when their dependencies change.effect()performs side effects (persistence to localStorage) whenever the underlying signal changes—no manual subscriptions necessary.- The UI stays fast and predictable without extra change detection work.
Optional: add a tiny HTTP service (mock) with Signals interop
If you later want to sync with an API, add a service and call it from the store. Use toSignal/toObservable interop for streaming scenarios. For most CRUD, keep the store as the single entry point so your components stay dumb.
What you can show in interviews
“I built a small Angular app where Signals are the single source of truth. I keep state in
signal(), derivecomputed()lists for filters and counts, and use aneffect()for persistence. Components are standalone and purely declarative: the UI re-renders precisely when the relevant signals change—no manual subscriptions or boilerplate.”
