Skip to content
Angular projects 4 min read

Todo App with Signals

A todo app is the perfect first project for learning modern Angular because it exercises every core idea you will reach for in real applications: reactive state, derived values, list rendering, forms, and persistence. In this guide you will build a complete todo app using signals for state, standalone components for structure, the new @if/@for control flow for templates, and localStorage for persistence. By the end you will have a small but production-shaped feature you can drop into any Angular 17+ project.

Project setup

Create a standalone Angular project. Recent CLI versions scaffold standalone components and signal-friendly defaults out of the box, so there is no NgModule to wire up.

ng new todo-signals --standalone --routing=false --style=css
cd todo-signals
ng generate component todo --standalone --inline-style

Angular’s signals API lives in @angular/core, so no extra dependencies are needed. The key primitives you will use are signal() for writable state, computed() for derived state, and effect() for side effects such as saving to storage.

Modeling the state

A signal holds a value and notifies any consumer when that value changes. We keep the entire todo list in one writable signal and expose derived counts through computed(). Computed signals are lazy and memoized — they only recalculate when a signal they read actually changes.

// src/app/todo.model.ts
export interface Todo {
  id: number;
  title: string;
  done: boolean;
}

Putting the logic in a service keeps the component thin and makes the state reusable and testable. The service owns the signals; the component only reads and triggers updates.

// src/app/todo.service.ts
import { Injectable, computed, effect, signal } from '@angular/core';
import { Todo } from './todo.model';

const STORAGE_KEY = 'todo-signals';

@Injectable({ providedIn: 'root' })
export class TodoService {
  // Load any persisted todos as the initial value.
  private readonly todos = signal<Todo[]>(this.load());

  // Public read-only views — components cannot mutate the array directly.
  readonly all = this.todos.asReadonly();
  readonly remaining = computed(() => this.todos().filter((t) => !t.done).length);
  readonly completed = computed(() => this.todos().filter((t) => t.done).length);

  constructor() {
    // Persist automatically whenever the list changes.
    effect(() => localStorage.setItem(STORAGE_KEY, JSON.stringify(this.todos())));
  }

  add(title: string): void {
    const trimmed = title.trim();
    if (!trimmed) return;
    this.todos.update((list) => [
      ...list,
      { id: Date.now(), title: trimmed, done: false },
    ]);
  }

  toggle(id: number): void {
    this.todos.update((list) =>
      list.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
    );
  }

  remove(id: number): void {
    this.todos.update((list) => list.filter((t) => t.id !== id));
  }

  clearCompleted(): void {
    this.todos.update((list) => list.filter((t) => !t.done));
  }

  private load(): Todo[] {
    try {
      return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]');
    } catch {
      return [];
    }
  }
}

The effect() runs once on creation and again on every change to a signal it reads. Because it reads this.todos(), persistence is fully automatic — there is no manual save() call to forget.

The component and template

The component injects the service with inject() and exposes the signals to the template. Reading a signal in a template (service.all()) automatically subscribes that view to changes, so Angular re-renders only the affected parts when state updates.

// src/app/todo.component.ts
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TodoService } from './todo.service';

@Component({
  selector: 'app-todo',
  standalone: true,
  imports: [FormsModule],
  templateUrl: './todo.component.html',
})
export class TodoComponent {
  protected readonly store = inject(TodoService);
  protected draft = '';

  submit(): void {
    this.store.add(this.draft);
    this.draft = '';
  }
}

The template uses the built-in control flow blocks. @for requires a track expression so Angular can identify each row efficiently, and @if/@else replaces the older *ngIf.

<!-- src/app/todo.component.html -->
<form (ngSubmit)="submit()">
  <input
    name="draft"
    [(ngModel)]="draft"
    placeholder="What needs doing?"
    autofocus
  />
  <button type="submit">Add</button>
</form>

<p>{{ store.remaining() }} remaining · {{ store.completed() }} done</p>

@if (store.all().length > 0) {
  <ul>
    @for (todo of store.all(); track todo.id) {
      <li>
        <label [class.done]="todo.done">
          <input
            type="checkbox"
            [checked]="todo.done"
            (change)="store.toggle(todo.id)"
          />
          {{ todo.title }}
        </label>
        <button (click)="store.remove(todo.id)">Delete</button>
      </li>
    }
  </ul>
  <button (click)="store.clearCompleted()">Clear completed</button>
} @else {
  <p>Nothing yet — add your first todo above.</p>
}

Running it

Bootstrap the standalone component and start the dev server.

ng serve

Output:

Application bundle generation complete. [2.314 seconds]
Watch mode enabled. Watching for file changes...
  ➜  Local:   http://localhost:4200/

Add a few todos, toggle and delete them, then refresh the page — the list survives because the effect() wrote it to localStorage on every change.

Signals vs. older patterns

The table below shows why signals are the default choice for new components.

ConcernRxJS / *ngIfSignals
Local stateBehaviorSubject + async pipesignal() read directly
Derived statecombineLatest / mapcomputed() — memoized
Side effectssubscribe() (manual teardown)effect() (auto-cleaned)
Template binding*ngFor, *ngIf@for with track, @if
Change detectionZone-wideFine-grained per signal

Best Practices

  • Keep state in a providedIn: 'root' service and expose it as read-only with asReadonly() so components mutate only through intent-named methods like add() and toggle().
  • Always update arrays and objects immutably (update((l) => [...l, item])) — replacing the reference is what triggers signal notifications.
  • Prefer computed() over storing derived counts in their own signal; derived state stays correct automatically and never drifts.
  • Use effect() only for genuine side effects (storage, logging, DOM) — never to write back into another signal, which can cause loops.
  • Give every @for block a stable track key so Angular reuses DOM nodes instead of recreating them on each change.
  • Wrap localStorage reads in try/catch so corrupted or missing data degrades gracefully to an empty list.
Last updated June 14, 2026
Was this helpful?