Angular Project 1: Build a Task Manager App Step by Step

Angular Tutorial: Beginner to Advanced · 12. Projects and deployment · 6 min read · Updated

Time to combine what you learned. In this project you build a Task Manager where you add tasks with a priority, mark them done, filter them, delete them, and keep them after a page refresh.

What you will practise

  • Signals, computed() and a store service
  • Reactive forms with validation
  • Component inputs and outputs
  • Saving data in localStorage
  • A unit test for the store

Step 1: The model

// src/app/manager/manager.model.ts
export type Priority = 'low' | 'medium' | 'high';
export type Filter = 'all' | 'open' | 'done';

export interface ManagedTask {
  id: number;
  title: string;
  priority: Priority;
  done: boolean;
}

Step 2: The store

The store holds the data and all the rules. Components stay small.

// src/app/manager/manager-store.ts
import { Injectable, computed, signal } from '@angular/core';
import { Filter, ManagedTask, Priority } from './manager.model';

const STORAGE_KEY = 'manager-tasks';

@Injectable({ providedIn: 'root' })
export class ManagerStore {
  private readonly tasks = signal<ManagedTask[]>(this.read());

  readonly filter = signal<Filter>('all');

  readonly visible = computed(() => {
    const mode = this.filter();
    return this.tasks().filter((task) =>
      mode === 'all' ? true : mode === 'done' ? task.done : !task.done,
    );
  });

  readonly total = computed(() => this.tasks().length);
  readonly openCount = computed(() => this.tasks().filter((task) => !task.done).length);

  add(title: string, priority: Priority): void {
    const clean = title.trim();
    if (!clean) {
      return;
    }
    const nextId = this.tasks().reduce((max, task) => Math.max(max, task.id), 0) + 1;
    this.update((list) => [...list, { id: nextId, title: clean, priority, done: false }]);
  }

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

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

  clearDone(): void {
    this.update((list) => list.filter((task) => !task.done));
  }

  private update(change: (list: ManagedTask[]) => ManagedTask[]): void {
    this.tasks.update(change);
    this.write();
  }

  private read(): ManagedTask[] {
    try {
      const raw = localStorage.getItem(STORAGE_KEY);
      return raw ? (JSON.parse(raw) as ManagedTask[]) : [];
    } catch {
      return []; // storage blocked or data damaged
    }
  }

  private write(): void {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(this.tasks()));
    } catch {
      // Ignore: the app still works, it just will not remember the tasks.
    }
  }
}

Two details matter here. The signal is never changed in place: we create new arrays and objects with map, filter and the spread operator. And every localStorage call is in try/catch, because browsers can block storage or the saved text can be damaged.

Step 3: The form component

// src/app/manager/manager-form.ts
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ManagerStore } from './manager-store';
import { Priority } from './manager.model';

@Component({
  selector: 'app-manager-form',
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()" novalidate>
      <label for="title">New task</label>
      <input id="title" formControlName="title" placeholder="What needs to be done?" />
      <label for="priority">Priority</label>
      <select id="priority" formControlName="priority">
        <option value="low">Low</option>
        <option value="medium">Medium</option>
        <option value="high">High</option>
      </select>
      <button type="submit">Add task</button>
      @if (form.controls.title.invalid && form.controls.title.touched) {
        <p role="alert">Enter a title of at least 3 characters.</p>
      }
    </form>
  `,
  styles: `
    form { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
  `,
})
export class ManagerForm {
  private readonly store = inject(ManagerStore);
  private readonly fb = inject(FormBuilder).nonNullable;

  protected readonly form = this.fb.group({
    title: ['', [Validators.required, Validators.minLength(3)]],
    priority: ['medium' as Priority],
  });

  protected submit(): void {
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      return;
    }
    const { title, priority } = this.form.getRawValue();
    this.store.add(title, priority);
    this.form.reset({ title: '', priority: 'medium' });
  }
}

Step 4: The list

// src/app/manager/manager-list.ts
import { Component, inject } from '@angular/core';
import { ManagerStore } from './manager-store';
import { Filter } from './manager.model';

@Component({
  selector: 'app-manager-list',
  template: `
    <p role="status">{{ store.openCount() }} open of {{ store.total() }} tasks</p>

    <div role="group" aria-label="Filter tasks">
      @for (f of filters; track f.value) {
        <button type="button" [attr.aria-pressed]="store.filter() === f.value" (click)="store.filter.set(f.value)">
          {{ f.label }}
        </button>
      }
      <button type="button" (click)="store.clearDone()">Clear completed</button>
    </div>

    <ul>
      @for (task of store.visible(); track task.id) {
        <li [class.done]="task.done">
          <label>
            <input type="checkbox" [checked]="task.done" (change)="store.toggle(task.id)" />
            {{ task.title }}
          </label>
          <span class="priority priority-{{ task.priority }}">{{ task.priority }}</span>
          <button type="button" (click)="store.remove(task.id)" [attr.aria-label]="'Delete ' + task.title">Delete</button>
        </li>
      } @empty {
        <li>No tasks to show.</li>
      }
    </ul>
  `,
  styles: `
    ul { list-style: none; padding: 0; }
    li { display: flex; gap: .75rem; align-items: center; padding: .4rem 0; border-bottom: 1px solid #ddd; }
    li.done label { text-decoration: line-through; opacity: .6; }
    button[aria-pressed='true'] { font-weight: bold; }
    .priority { font-size: .8rem; padding: 0 .5rem; border-radius: 1rem; background: #eee; }
    .priority-high { background: #fcd5d5; }
    .priority-medium { background: #fdf0c4; }
    .priority-low { background: #d6efd6; }
  `,
})
export class ManagerList {
  protected readonly store = inject(ManagerStore);

  protected readonly filters: { value: Filter; label: string }[] = [
    { value: 'all', label: 'All' },
    { value: 'open', label: 'Open' },
    { value: 'done', label: 'Done' },
  ];
}

Step 5: Put it together

// src/app/app.ts
import { Component } from '@angular/core';
import { ManagerForm } from './manager/manager-form';
import { ManagerList } from './manager/manager-list';

@Component({
  imports: [ManagerForm, ManagerList],
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
  <h1>Task Manager</h1>
  <app-manager-form />
  <app-manager-list />
</main>

Run ng serve, add a few tasks and refresh the page. The tasks stay.

Step 6: Test the store

// src/app/manager/manager-store.spec.ts
import { TestBed } from '@angular/core/testing';
import { ManagerStore } from './manager-store';

describe('ManagerStore', () => {
  let store: ManagerStore;

  beforeEach(() => {
    localStorage.clear();
    store = TestBed.inject(ManagerStore);
  });

  it('adds a task and ignores an empty title', () => {
    store.add('Learn signals', 'high');
    store.add('   ', 'low');
    expect(store.total()).toBe(1);
    expect(store.openCount()).toBe(1);
  });

  it('toggles and filters tasks', () => {
    store.add('One', 'low');
    store.add('Two', 'medium');
    store.toggle(1);

    store.filter.set('done');
    expect(store.visible().map((t) => t.title)).toEqual(['One']);

    store.filter.set('open');
    expect(store.visible().map((t) => t.title)).toEqual(['Two']);
  });

  it('removes completed tasks', () => {
    store.add('One', 'low');
    store.add('Two', 'low');
    store.toggle(1);
    store.clearDone();
    expect(store.total()).toBe(1);
  });

  it('saves tasks in localStorage', () => {
    store.add('Remember me', 'low');
    expect(localStorage.getItem('manager-tasks')).toContain('Remember me');
  });
});

Run ng test --watch=false. All tests should pass.

Challenges

  1. Add an "Edit" button that changes the title of a task.
  2. Sort tasks by priority (high first) using computed().
  3. Add a due date field and highlight overdue tasks.
  4. Move the page into a route and lazy load it.

Quick check

  1. Why does the store create new arrays instead of changing the old one?
  2. Why is localStorage wrapped in try/catch?
  3. Which part of the app holds the business rules?

Next project: an Employee Management Dashboard.

Frequently asked questions

Should I use a state library such as NgRx?

Not for a small app. A signal-based service is enough. Consider NgRx or similar when many teams share complex state.

Why not store the filtered list as its own signal?

Derived data belongs in computed(), so it can never get out of sync with the source list.