LearnAI ToolsCareerPractice BuildsPlayContact
AngularBeginner~1.5 hours

Todo App

Build a task manager with filtering and reactive state using Signals.

Signalscomputed()Two-Way Binding

Overview

A todo app is the classic first Angular project because it exercises the three ideas everything else in modern Angular builds on: reactive state created with `signal()`, a template that reads and writes that state through property/event binding and the `@for` control-flow block, and a `computed()` signal that derives new data from state without ever duplicating it. Nothing about it needs a service, routing, or a server, so it stays focused on those fundamentals.

By the end of this tutorial you will have a task manager where a single `tasks` signal on the root `AppComponent` is the one source of truth. A `TodoItemComponent` renders each row from a `task` `@Input()` and emits `@Output()` events back up when it is clicked, a `FilterBarComponent` switches between All/Active/Completed views, and an `effect()` keeps every change saved to `localStorage` so the list survives a page refresh.

What You'll Build
  • A `tasks = signal<Task[]>([])` on `AppComponent`, always updated immutably — Angular signals compare by reference, so a brand-new array is built on every change, unlike Vue's Proxy-based mutation.
  • A `TodoItemComponent` that receives a task through `@Input()` and emits `toggle`/`delete` events through `@Output()` instead of changing state itself.
  • A form using `[(ngModel)]` to add a new task without reloading the page — genuine Angular two-way binding.
  • Click-to-toggle-complete and a delete button, both implemented as emitted events handled in `AppComponent`.
  • A `FilterBarComponent` for switching between All, Active, and Completed views.
  • Automatic saving to and loading from `localStorage` with `effect()`, so tasks persist across reloads.

Prerequisites

  • Standalone components — a component that lists its own `imports: [...]` array instead of belonging to an `NgModule`. Every component in this course uses this modern style; no `NgModule` appears anywhere below.
  • Angular template syntax basics — property binding (`[value]`), event binding (`(click)`), and the newer `@if`/`@for` control-flow blocks (the direct replacement for the older `*ngIf`/`*ngFor` structural directives).
  • `@Input()`/`@Output()` and TypeScript interfaces — passing data into a component and having it notify its parent of changes.
  • Solid core TypeScript — array methods (`map`, `filter`, `find`), spreading objects/arrays with `...`, and arrow functions.
  • This is your first Angular project, so `signal`, `computed`, and `effect` are introduced here from scratch — you do not need to know them already.

Project Structure

The app is split into three components, each with one clear job. `AppComponent` is the only component that holds state — it owns the `tasks` signal, the current `filter` signal, and every method that changes them. `TodoItemComponent` and `FilterBarComponent` hold no state of their own; they are purely `@Input()`/`@Output()`-driven, meaning everything they display arrives as an input and everything they trigger leaves as an emitted output. Keeping state in exactly one place is what guarantees the list, the filter buttons, and `localStorage` can never disagree about what the current tasks actually are.

src/app/
app.component.ts // Owns tasks and filter signals; the only component that changes them
app.component.html
app.component.css // Shared dark-theme styling for the whole app
todo-item/
todo-item.component.ts // One task row; purely @Input()-in / @Output()-out, no state of its own
todo-item.component.html
filter-bar/
filter-bar.component.ts // The All / Active / Completed buttons; purely @Input()-in / @Output()-out
filter-bar.component.html
main.ts // Bootstraps <AppComponent> with bootstrapApplication()

Here is the shared stylesheet used by every component below. It does not change again after this — every remaining step only edits component `.ts`/`.html` files.

/* app.component.css */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Arial, Helvetica, sans-serif;
background: #1a1d23;
color: #e8e8e8;
display: flex;
justify-content: center;
padding: 40px 16px;
}
.todo-app {
width: 100%;
max-width: 420px;
background: #23262e;
border-radius: 10px;
padding: 24px;
}
.todo-app h1 {
margin-bottom: 16px;
font-size: 1.5rem;
}
.todo-form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.todo-form input {
flex: 1; /* input grows to fill the space; the button keeps its natural width */
padding: 10px 12px;
border-radius: 6px;
border: 1px solid #3a3f4b;
background: #1a1d23;
color: #e8e8e8;
font-size: 1rem;
}
.todo-form button {
padding: 10px 16px;
border: none;
border-radius: 6px;
background: #dd0031;
color: white;
cursor: pointer;
font-weight: bold;
}
.filter-bar {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.filter-btn {
flex: 1;
padding: 8px 0;
border: 1px solid #3a3f4b;
border-radius: 6px;
background: transparent;
color: #9aa4b2;
cursor: pointer;
}
.filter-btn.active {
background: #dd0031;
color: white;
border-color: #dd0031;
}
.todo-list {
list-style: none;
}
.todo-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 4px;
border-bottom: 1px solid #3a3f4b;
}
.todo-item.completed .todo-text {
text-decoration: line-through; /* visual cue that a task is finished */
opacity: 0.6;
}
.todo-text {
flex: 1;
cursor: pointer; /* clicking the text (not just a checkbox) toggles completion */
}
.delete-btn {
background: transparent;
border: none;
color: #ff6b6b;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
}
.empty-message {
text-align: center;
color: #888;
padding: 12px 0;
}

Step 1: Set Up Reactive State with Signals

`signal()` wraps a value in a reactive container — calling it as a function (`tasks()`) is what Angular tracks to know when to re-render. `AppComponent` needs two independent pieces of signal state: the tasks themselves and which filter is active. The text currently being typed into the "add task" input is kept as a plain component property instead, since this project uses it to demonstrate genuine two-way binding with `ngModel`, a separate concept from signal-driven state.

// app.component.ts
import { Component, signal } from '@angular/core';
// Every task will be a plain object shaped like this — the same shape a
// plain-TypeScript version of this project would use, since Angular signal
// state is still just ordinary data wrapped in a reactive container
interface Task {
id: number;
text: string;
completed: boolean;
}
@Component({
selector: 'app-root',
standalone: true, // no NgModule needed — this component declares its own imports directly
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
// tasks is the single source of truth for every task in the list — every
// other piece of UI (the rendered rows, the filter counts) is derived FROM
// this array whenever it's read, never stored as a separate copy
tasks = signal<Task[]>([]);
// What the user is currently typing in the "add task" input. A plain
// string property (not a signal) — two-way bound directly with ngModel in
// Step 3, kept separate from tasks since draft text isn't a task yet
newTaskText = '';
// 'all' | 'active' | 'completed' — its own signal since the filter
// changes independently of the task list itself
filter = signal<'all' | 'active' | 'completed'>('all');
}
<!-- app.component.html -->
<div class="todo-app">
<h1>My Tasks</h1>
<!-- Form, list, and filter bar are filled in over the next steps -->
</div>

Step 2: Render Tasks with a TodoItem Component

`TodoItemComponent` is a separate component so `AppComponent`'s template stays a simple `@for` instead of a long block of row-building markup. It receives a `task` object through `@Input()` and declares two `@Output()` events it can emit — it never touches `tasks` directly, because `AppComponent` is the only component allowed to change that state.

// todo-item/todo-item.component.ts
import { Component, EventEmitter, Input, Output } from '@angular/core';
interface Task {
id: number;
text: string;
completed: boolean;
}
@Component({
selector: 'app-todo-item',
standalone: true,
templateUrl: './todo-item.component.html',
})
export class TodoItemComponent {
// @Input({ required: true }) receives one task object from the parent —
// this component owns no state of its own, everything it needs to display
// arrives as an input, which is what keeps AppComponent's tasks signal as
// the one and only source of truth
@Input({ required: true }) task!: Task;
// @Output() + EventEmitter is Angular's inputs-in/events-out pattern —
// this component never mutates task directly, it only asks the parent to
// toggle/delete via an emitted event
@Output() toggle = new EventEmitter<number>();
@Output() delete = new EventEmitter<number>();
}
<!-- todo-item/todo-item.component.html -->
<li class="todo-item" [class.completed]="task.completed">
<!-- Clicking the text emits an event for AppComponent to handle instead of
mutating task.completed directly — this component has no access to
AppComponent's tasks signal and shouldn't need it -->
<span class="todo-text" (click)="toggle.emit(task.id)">{{ task.text }}</span>
<button type="button" class="delete-btn" (click)="delete.emit(task.id)">&times;</button>
</li>

Back in `AppComponent`, `@for` turns the array into one `<app-todo-item>` per task. The `track task.id` clause is required on every `@for` block — it is how Angular matches each row to the same underlying task across re-renders instead of throwing every row away and rebuilding it from scratch (the direct replacement for the older `*ngFor="let task of tasks; trackBy: trackById"` pattern). The `(toggle)`/`(delete)` handlers are wired up once real methods exist for them, in Step 4.

// app.component.ts (updated imports)
import { Component, signal } from '@angular/core';
import { TodoItemComponent } from './todo-item/todo-item.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [TodoItemComponent], // must be listed explicitly — standalone components import exactly what their template uses
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
tasks = signal<Task[]>([]);
newTaskText = '';
filter = signal<'all' | 'active' | 'completed'>('all');
}
<!-- app.component.html -->
<div class="todo-app">
<h1>My Tasks</h1>
@if (tasks().length === 0) {
<p class="empty-message">No tasks yet. Add one below to get started!</p>
}
<ul class="todo-list">
@for (task of tasks(); track task.id) {
<app-todo-item [task]="task" />
}
</ul>
</div>

Step 3: Add a New Task with Two-Way Binding

`[(ngModel)]` is Angular's "banana in a box" syntax for two-way binding — it binds the input's value to `newTaskText` AND listens for input events to update that property, in one directive, so the property and the visible input can never drift apart. It comes from `FormsModule`, which has to be added to the component's `imports` array since standalone components import exactly what they use. Submitting the form builds a new task object and adds it to `tasks`.

// app.component.ts
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; // required for [(ngModel)] on a plain component property
import { TodoItemComponent } from './todo-item/todo-item.component';
interface Task {
id: number;
text: string;
completed: boolean;
}
@Component({
selector: 'app-root',
standalone: true,
imports: [FormsModule, TodoItemComponent],
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
tasks = signal<Task[]>([]);
newTaskText = '';
filter = signal<'all' | 'active' | 'completed'>('all');
handleSubmit(): void {
const trimmed = this.newTaskText.trim(); // trim() so a string of only spaces doesn't create a blank task
if (trimmed === '') {
return; // silently ignore empty submissions
}
const newTask: Task = {
id: Date.now(), // a timestamp is unique enough for a client-only list; a real backend would assign this instead
text: trimmed,
completed: false,
};
// .update() receives the current array and must return a brand-new one —
// unlike Vue's Proxy-based reactivity, Angular signals compare the value
// they're set to by reference, so this is how a change gets detected
this.tasks.update((current) => [...current, newTask]);
this.newTaskText = ''; // clear the input now that the task was added
}
}
<!-- app.component.html -->
<div class="todo-app">
<h1>My Tasks</h1>
<!-- (ngSubmit) stops the browser's default full-page reload on submit;
name="newTaskText" is required by ngModel inside a template-driven form -->
<form class="todo-form" (ngSubmit)="handleSubmit()">
<input
[(ngModel)]="newTaskText"
name="newTaskText"
type="text"
placeholder="What do you need to do?"
autocomplete="off"
/>
<button type="submit">Add</button>
</form>
<!-- task list from Step 2 stays below the form -->
</div>
What Happens in the tasks Signal

Click Run to see what this code prints.

Step 4: Toggle and Delete Tasks

Both `toggleTask` and `deleteTask` live in `AppComponent`, right next to `tasks`, and get passed down to every `TodoItemComponent` as `(toggle)`/`(delete)` handlers. Because a signal is compared by reference, both methods build a brand-new array with `.map()`/`.filter()` inside `.update()` rather than mutating an element in place — mutating `tasks()[i].completed` directly would silently fail to trigger a re-render, since Angular would see the exact same array reference and assume nothing changed.

// app.component.ts (additional methods)
toggleTask(id: number): void {
// .map() builds a new array where only the matching task is a new object —
// every other task keeps its exact same reference, which also lets
// Angular's change detection skip re-rendering rows that didn't change
this.tasks.update((current) =>
current.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
}
deleteTask(id: number): void {
this.tasks.update((current) => current.filter((t) => t.id !== id));
}
<!-- app.component.html (updated) -->
<ul class="todo-list">
<!-- Now the real handlers replace the unbound placeholders from Step 2 -->
@for (task of tasks(); track task.id) {
<app-todo-item
[task]="task"
(toggle)="toggleTask($event)"
(delete)="deleteTask($event)"
/>
}
</ul>

Step 5: Filter Tasks with a computed() Signal

`computed()` creates a signal that recalculates automatically whenever the signals it reads change, and — unlike a plain getter method — caches its result until one of those dependencies actually changes. A getter re-runs its entire body on every single change-detection pass regardless of whether `tasks` or `filter` actually changed; `computed()` only re-runs when they do. `filteredTasks` is never stored in its own `signal`; deriving it instead of duplicating it means the filtered view can never fall out of sync with the real task list.

// filter-bar/filter-bar.component.ts
import { Component, EventEmitter, Input, Output } from '@angular/core';
type Filter = 'all' | 'active' | 'completed';
@Component({
selector: 'app-filter-bar',
standalone: true,
templateUrl: './filter-bar.component.html',
})
export class FilterBarComponent {
// A plain, purely @Input()-in / @Output()-out component for the filter
// buttons — naming the output "filterChange" to pair with the "filter"
// input is Angular's convention for enabling [(filter)] two-way syntax on
// this component, the same idea [(ngModel)] uses under the hood
@Input({ required: true }) filter!: Filter;
@Output() filterChange = new EventEmitter<Filter>();
readonly filters: Filter[] = ['all', 'active', 'completed'];
}
<!-- filter-bar/filter-bar.component.html -->
<div class="filter-bar">
@for (f of filters; track f) {
<button
type="button"
class="filter-btn"
[class.active]="filter === f"
(click)="filterChange.emit(f)"
>
{{ f.charAt(0).toUpperCase() + f.slice(1) }}
</button>
}
</div>
// app.component.ts
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TodoItemComponent } from './todo-item/todo-item.component';
import { FilterBarComponent } from './filter-bar/filter-bar.component';
interface Task {
id: number;
text: string;
completed: boolean;
}
@Component({
selector: 'app-root',
standalone: true,
imports: [FormsModule, TodoItemComponent, FilterBarComponent],
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
tasks = signal<Task[]>([]);
newTaskText = '';
filter = signal<'all' | 'active' | 'completed'>('all');
// Computed once, re-evaluated only when tasks() or filter() actually
// change — derived data, not its own piece of state
filteredTasks = computed(() => {
const currentFilter = this.filter();
if (currentFilter === 'active') return this.tasks().filter((t) => !t.completed);
if (currentFilter === 'completed') return this.tasks().filter((t) => t.completed);
return this.tasks(); // currentFilter === 'all'
});
handleSubmit(): void {
const trimmed = this.newTaskText.trim();
if (trimmed === '') return;
this.tasks.update((current) => [...current, { id: Date.now(), text: trimmed, completed: false }]);
this.newTaskText = '';
}
toggleTask(id: number): void {
this.tasks.update((current) => current.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)));
}
deleteTask(id: number): void {
this.tasks.update((current) => current.filter((t) => t.id !== id));
}
}
<!-- app.component.html -->
<div class="todo-app">
<h1>My Tasks</h1>
<form class="todo-form" (ngSubmit)="handleSubmit()">
<input [(ngModel)]="newTaskText" name="newTaskText" type="text" placeholder="What do you need to do?" autocomplete="off" />
<button type="submit">Add</button>
</form>
<!-- explicit [filter]/(filterChange) rather than the [(filter)] shorthand,
so the .set() call on the filter signal stays visible in the template -->
<app-filter-bar [filter]="filter()" (filterChange)="filter.set($event)" />
@if (filteredTasks().length === 0) {
<p class="empty-message">No tasks to show.</p>
}
<ul class="todo-list">
<!-- now loops over filteredTasks() instead of the raw tasks() list -->
@for (task of filteredTasks(); track task.id) {
<app-todo-item [task]="task" (toggle)="toggleTask($event)" (delete)="deleteTask($event)" />
}
</ul>
</div>

Step 6: Persist Tasks with an effect()

Loading and saving use two different Angular tools. Loading happens in `ngOnInit`, which runs once after the component is created — reading `localStorage` there (rather than leaving `tasks` empty until some later event) avoids a flash of an empty list. Saving uses `effect()`, which runs its callback once immediately and then automatically re-runs it whenever any signal it reads — here, `tasks()` — changes. `effect()` must be created inside an injection context, which the constructor always is, so it is set up there rather than in `ngOnInit`.

// app.component.ts
import { Component, OnInit, computed, effect, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TodoItemComponent } from './todo-item/todo-item.component';
import { FilterBarComponent } from './filter-bar/filter-bar.component';
interface Task {
id: number;
text: string;
completed: boolean;
}
@Component({
selector: 'app-root',
standalone: true,
imports: [FormsModule, TodoItemComponent, FilterBarComponent],
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent implements OnInit {
private static readonly STORAGE_KEY = 'angular-todo-app-tasks';
tasks = signal<Task[]>([]);
newTaskText = '';
filter = signal<'all' | 'active' | 'completed'>('all');
filteredTasks = computed(() => {
const currentFilter = this.filter();
if (currentFilter === 'active') return this.tasks().filter((t) => !t.completed);
if (currentFilter === 'completed') return this.tasks().filter((t) => t.completed);
return this.tasks();
});
constructor() {
// effect() must be created within an injection context — a constructor
// qualifies automatically. It runs once right away, then re-runs
// whenever tasks() changes, keeping localStorage in sync automatically
effect(() => {
localStorage.setItem(AppComponent.STORAGE_KEY, JSON.stringify(this.tasks()));
});
}
ngOnInit(): void {
// Loading happens once, in ngOnInit rather than a signal-reactive
// effect, because it should run exactly once at startup — unlike
// saving, which needs to fire again on every future change to tasks()
const saved = localStorage.getItem(AppComponent.STORAGE_KEY);
if (saved) {
this.tasks.set(JSON.parse(saved));
}
}
handleSubmit(): void {
const trimmed = this.newTaskText.trim();
if (trimmed === '') return;
this.tasks.update((current) => [...current, { id: Date.now(), text: trimmed, completed: false }]);
this.newTaskText = '';
}
toggleTask(id: number): void {
this.tasks.update((current) => current.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)));
}
deleteTask(id: number): void {
this.tasks.update((current) => current.filter((t) => t.id !== id));
}
}

Angular also offers `effect()`'s untracked reads and manual cleanup functions for more advanced cases — not needed here since this effect only ever reads one signal and never needs to cancel in-progress work.

Complete Code

Here is the complete project — the stylesheet and every component file, fully assembled from the steps above, written the way a real Angular CLI-generated project would structure it.

/* app.component.css */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Arial, Helvetica, sans-serif;
background: #1a1d23;
color: #e8e8e8;
display: flex;
justify-content: center;
padding: 40px 16px;
}
.todo-app { width: 100%; max-width: 420px; background: #23262e; border-radius: 10px; padding: 24px; }
.todo-app h1 { margin-bottom: 16px; font-size: 1.5rem; }
.todo-form { display: flex; gap: 8px; margin-bottom: 16px; }
.todo-form input { flex: 1; padding: 10px 12px; border-radius: 6px; border: 1px solid #3a3f4b; background: #1a1d23; color: #e8e8e8; font-size: 1rem; }
.todo-form button { padding: 10px 16px; border: none; border-radius: 6px; background: #dd0031; color: white; cursor: pointer; font-weight: bold; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 16px; }
.filter-btn { flex: 1; padding: 8px 0; border: 1px solid #3a3f4b; border-radius: 6px; background: transparent; color: #9aa4b2; cursor: pointer; }
.filter-btn.active { background: #dd0031; color: white; border-color: #dd0031; }
.todo-list { list-style: none; }
.todo-item { display: flex; align-items: center; gap: 10px; padding: 10px 4px; border-bottom: 1px solid #3a3f4b; }
.todo-item.completed .todo-text { text-decoration: line-through; opacity: 0.6; }
.todo-text { flex: 1; cursor: pointer; }
.delete-btn { background: transparent; border: none; color: #ff6b6b; cursor: pointer; font-size: 1.1rem; line-height: 1; }
.empty-message { text-align: center; color: #888; padding: 12px 0; }
// todo-item/todo-item.component.ts
import { Component, EventEmitter, Input, Output } from '@angular/core';
interface Task {
id: number;
text: string;
completed: boolean;
}
@Component({
selector: 'app-todo-item',
standalone: true,
templateUrl: './todo-item.component.html',
})
export class TodoItemComponent {
@Input({ required: true }) task!: Task;
@Output() toggle = new EventEmitter<number>();
@Output() delete = new EventEmitter<number>();
}
<!-- todo-item/todo-item.component.html -->
<li class="todo-item" [class.completed]="task.completed">
<span class="todo-text" (click)="toggle.emit(task.id)">{{ task.text }}</span>
<button type="button" class="delete-btn" (click)="delete.emit(task.id)">&times;</button>
</li>
// filter-bar/filter-bar.component.ts
import { Component, EventEmitter, Input, Output } from '@angular/core';
type Filter = 'all' | 'active' | 'completed';
@Component({
selector: 'app-filter-bar',
standalone: true,
templateUrl: './filter-bar.component.html',
})
export class FilterBarComponent {
@Input({ required: true }) filter!: Filter;
@Output() filterChange = new EventEmitter<Filter>();
readonly filters: Filter[] = ['all', 'active', 'completed'];
}
<!-- filter-bar/filter-bar.component.html -->
<div class="filter-bar">
@for (f of filters; track f) {
<button type="button" class="filter-btn" [class.active]="filter === f" (click)="filterChange.emit(f)">
{{ f.charAt(0).toUpperCase() + f.slice(1) }}
</button>
}
</div>
// app.component.ts
import { Component, OnInit, computed, effect, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TodoItemComponent } from './todo-item/todo-item.component';
import { FilterBarComponent } from './filter-bar/filter-bar.component';
interface Task {
id: number;
text: string;
completed: boolean;
}
@Component({
selector: 'app-root',
standalone: true,
imports: [FormsModule, TodoItemComponent, FilterBarComponent],
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent implements OnInit {
private static readonly STORAGE_KEY = 'angular-todo-app-tasks';
tasks = signal<Task[]>([]);
newTaskText = '';
filter = signal<'all' | 'active' | 'completed'>('all');
filteredTasks = computed(() => {
const currentFilter = this.filter();
if (currentFilter === 'active') return this.tasks().filter((t) => !t.completed);
if (currentFilter === 'completed') return this.tasks().filter((t) => t.completed);
return this.tasks();
});
constructor() {
effect(() => {
localStorage.setItem(AppComponent.STORAGE_KEY, JSON.stringify(this.tasks()));
});
}
ngOnInit(): void {
const saved = localStorage.getItem(AppComponent.STORAGE_KEY);
if (saved) this.tasks.set(JSON.parse(saved));
}
handleSubmit(): void {
const trimmed = this.newTaskText.trim();
if (trimmed === '') return;
this.tasks.update((current) => [...current, { id: Date.now(), text: trimmed, completed: false }]);
this.newTaskText = '';
}
toggleTask(id: number): void {
this.tasks.update((current) => current.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)));
}
deleteTask(id: number): void {
this.tasks.update((current) => current.filter((t) => t.id !== id));
}
}
<!-- app.component.html -->
<div class="todo-app">
<h1>My Tasks</h1>
<form class="todo-form" (ngSubmit)="handleSubmit()">
<input [(ngModel)]="newTaskText" name="newTaskText" type="text" placeholder="What do you need to do?" autocomplete="off" />
<button type="submit">Add</button>
</form>
<app-filter-bar [filter]="filter()" (filterChange)="filter.set($event)" />
@if (filteredTasks().length === 0) {
<p class="empty-message">No tasks to show.</p>
}
<ul class="todo-list">
@for (task of filteredTasks(); track task.id) {
<app-todo-item [task]="task" (toggle)="toggleTask($event)" (delete)="deleteTask($event)" />
}
</ul>
</div>
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent).catch((err) => console.error(err));

Sample Run

Sample Interaction

Click Run to see what this code prints.

Extend This Project

  • Add an `editing` signal to `TodoItemComponent` so double-clicking a task turns its text into an `<input>`, saved on blur or Enter.
  • Add a "Clear Completed" button in `AppComponent` that filters every completed task out of `tasks` in one click.
  • Add a due date with `<input type="date">` in the form, stored as another field on each task object.
  • Show a live count of remaining active tasks next to the filter bar, computed the same derived way as `filteredTasks`.
  • Extract the `localStorage` load/save logic into an injectable `TaskStorageService` so other projects can share it.

Summary

You built a complete, persistent Todo App using `signal()` for state, `@Input()`/`@Output()` to pass data and behavior between components, and `effect()` to synchronize that state with `localStorage`. The core pattern — state lives in one place, gets passed down as inputs, and changes flow back up through emitted outputs — is the foundation for every Angular project you build next, including the other three in this course.