Angular 18 and Signals: How to Build a Simple Todo List App

JohnJohn
7 min read

In this post, we'll create a To-Do List application using Angular 18 and Signal. This app will let users add, edit, and delete tasks, mark tasks as complete and filter tasks by their status.

  1. Create a new Angular project

    Open a terminal and type the following command:

     ng new todo-list
    

    It will create a new Angular project named todo-list. Then, go to the project directory:

     cd todo-list
    
  2. Create project structure

    Let's create some folders for feature entities. In the terminal, type the following commands:

     cd src/app
     mkdir features
    
     cd features
     mkdir task
    
     cd task
     mkdir components 
     mkdir services 
     mkdir models
    

    After this, your project will have the following structure:

     src
     -- app
       -- features
           -- task
              -- components
              -- services
              -- models
    

    This structure will help maintain a clean architecture.

  3. Create components

    Let's go to task folder and create some Angular entities:

     cd src/app/features/task/components
     ng generate component task-form
     ng g c task-list // you can use short alias of comands
    
     cd .. // return to parent task folder
     cd services 
     ng g s tasks
    
  4. Create a model

    In folder src/app/features/task/models create file with name task.model.ts.

    Define Task interface and TaskStatus enum:

     export interface Task {
       id: number;
       title: string;
       completed: boolean;
     }
    
     export enum TaskStatus {
       All = 'All',
       Active = 'Active',
       Completed = 'Completed',
     }
    
  5. Define logic in the service

    Angular provides a new feature called Signals to store reactive data. It's a great mechanism for manipulating data and we can use it instead of RxJS. For more details about Signals, read here.

    We need to store tasks, so open task.service.ts and add the following code:

     import { computed, Injectable, signal } from '@angular/core';
     import { Task } from '../models/task.model';
    
     @Injectable({
       providedIn: 'root'
     })
     export class TaskService {
       // private array to manage tasks in service
       private tasks = signal<Task[]>([]);
       // id to identify each task
       private nextId = 1;
    
       // public variable for all tasks
       public tasks$ = this.tasks.asReadonly();
       // public variable for active tasks
       public activeTasks$ = computed(() => this.tasks().filter(task => !task.completed));
       // public variable for completed tasks  
       public completedTasks$ = computed(() => this.tasks().filter(task => task.completed));
     }
    

    Let's define the first methods to add and remove tasks:

     public addTask(title: string): void {
       const newTask: Task = {
         id: this.nextId++,
         title,
         completed: false
       };
       this.tasks.update((tasks: Task[]) => [...tasks, newTask]);
     }
    
     public removeTask(id: number): void {
       this.tasks.update((tasks: Task[]) =>
         tasks.filter(task => task.id !== id)
       );
     }
    

    We should also provide the ability to edit the task title:

     public editTask(id: number, title: string): void {
       this.tasks.update((tasks: Task[]) =>
         tasks.map((task: Task) =>
           task.id === id ? { ...task, title: title } : task
         )
       );
     }
    

    And lastly, we want to mark a task as completed, so let's create the toggleTask method:

     public toggleTask(id: number): void {
       this.tasks.update((tasks: Task[]) =>
         tasks.map((task: Task) =>
           task.id === id ? { ...task, completed: !task.completed } : task
         )
       );
     }
    

    The complete TaskService looks like this:

     import { computed, Injectable, signal } from '@angular/core';
     import { Task } from '../models/task.model';
    
     @Injectable({
       providedIn: 'root'
     })
     export class TaskService {
       private tasks = signal<Task[]>([]);
       private nextId = 1;
    
       public tasks$ = this.tasks.asReadonly();
       public activeTasks$ = computed(() =>
        this.tasks().filter(task => !task.completed)
       );
       public completedTasks$ = computed(() =>
        this.tasks().filter(task => task.completed)
       );
    
       public addTask(title: string): void {
         const newTask: Task = {
           id: this.nextId++,
           title,
           completed: false
         };
         this.tasks.update((tasks: Task[]) => [...tasks, newTask]);
       }
    
       public removeTask(id: number): void {
         this.tasks.update((tasks: Task[]) =>
           tasks.filter(task => task.id !== id)
         );
       }
    
       public editTask(id: number, title: string): void {
         this.tasks.update((tasks: Task[]) =>
           tasks.map((task: Task) =>
             task.id === id ? { ...task, title: title } : task
           )
         );
       }
    
       public toggleTask(id: number): void {
         this.tasks.update((tasks: Task[]) =>
           tasks.map((task: Task) =>
             task.id === id
               ? { ...task, completed: !task.completed } : task
           )
         );
       }
     }
    
  6. Implement components

    Let's define task-form component:

     import { ChangeDetectionStrategy, Component } from '@angular/core';
     import { TaskService } from '../../services/task.service';
     import { FormsModule } from '@angular/forms';
    
     @Component({
       selector: 'app-task-form',
       standalone: true,
       imports: [
         FormsModule
       ],
       templateUrl: './task-form.component.html',
       styleUrl: './task-form.component.scss',
       changeDetection: ChangeDetectionStrategy.OnPush
     })
     export class TaskFormComponent {
       private readonly taskService = inject(TaskService);
    
       public title = '';
    
       public addTask(): void {
         if (this.title.trim()) {
           this.taskService.addTask(this.title);
           this.title = '';
         }
       }
     }
    

    add html to template:

     <div class="task-form">
       <input class="task-input" [(ngModel)]="title" (keyup.enter)="addTask()" placeholder="Add new task">
       <button type="button" class="add-button" (click)="addTask()">Add</button>
     </div>
    

    add css rules to styles:

     .task-form {
       display: flex;
       margin-bottom: 20px;
     }
    
     .task-input {
       flex-grow: 1;
       padding: 10px;
       border: 1px solid #ccc;
       border-radius: 4px;
       margin-right: 10px;
     }
    
     .add-button {
       padding: 10px 20px;
       background-color: #007bff;
       color: white;
       border: none;
       border-radius: 4px;
       cursor: pointer;
       transition: background-color 0.3s;
     }
    
     .add-button:hover {
       background-color: #0056b3;
     }
    

    We also need to define the task-list component:

     import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
     import { Task, TaskStatus } from '../../models/task.model';
     import { TaskService } from '../../services/task.service';
     import { FormsModule } from '@angular/forms';
     import { AsyncPipe, JsonPipe } from '@angular/common';
     import { animate, keyframes, style, transition, trigger } from '@angular/animations';
    
     @Component({
       selector: 'app-task-list',
       standalone: true,
       imports: [
         FormsModule,
         JsonPipe,
         AsyncPipe,
       ],
       templateUrl: './task-list.component.html',
       styleUrl: './task-list.component.scss',
       changeDetection: ChangeDetectionStrategy.OnPush,
     })
     export class TaskListComponent {
       private taskService: TaskService = inject(TaskService);
    
       public tasks = this.taskService.tasks$;
    
       public filter = TaskStatus.All;
    
       public editTaskId: number | null = null;
       public editTaskTitle: string = '';
    
       get filteredTasks() {
         return computed(() => {
           switch (this.filter) {
             case TaskStatus.Active:
               return this.taskService.activeTasks$();
             case TaskStatus.Completed:
               return this.taskService.completedTasks$();
             default:
               return this.tasks();
           }
         });
       }
    
       public toggleTask(id: number): void {
         this.taskService.toggleTask(id);
       }
    
       public deleteTask(id: number): void {
         this.taskService.removeTask(id);
       }
    
       public startEditTask(task: Task): void {
         this.editTaskId = task.id;
         this.editTaskTitle = task.title;
       }
    
       public saveEditTask(id: number): void {
         if (this.editTaskTitle.trim()) {
           this.taskService.editTask(id, this.editTaskTitle);
           this.editTaskId = null;
           this.editTaskTitle = '';
         }
       }
     }
    

    add html to template:

     @if (tasks(); as tasks) {
       <div>
         <div class="select-wrapper">
           <select [(ngModel)]="filter">
             <option value="All">All</option>
             <option value="Active">Active</option>
             <option value="Completed">Completed</option>
           </select>
         </div>
    
         @if (tasks.length > 0) {
           <ul class="task-list">
             @for (task of filteredTasks(); track task.id) {
               <li class="task-item">
                 <input
                   type="checkbox"
                   [checked]="task.completed"
                   (change)="toggleTask(task.id)" />
    
                 @if (editTaskId === task.id) {
                   <input
                     type="text"
                     [(ngModel)]="editTaskTitle"
                     (blur)="saveEditTask(task.id)"
                     (keyup.enter)="saveEditTask(task.id)"
                     class="edit-input" />
                 } @else {
                   <span (dblclick)="startEditTask(task)">
                   {{ task.title }}
                 </span>
                 }
    
                 <button class="delete-button" (click)="deleteTask(task.id)">Delete</button>
               </li>
             }
           </ul>
         } @else {
           No tasks available
         }
       </div>
     }
    

    add css to styles:

     .select-wrapper {
       position: relative;
     }
    
     .select-wrapper::after {
       content: "⌵";
       font-size: 1rem;
       top: 6px;
       right: 10px;
       position: absolute;
     }
    
     select {
       -webkit-appearance: none;
       appearance: none;
       width: 100%;
       padding: 10px;
       margin-bottom: 20px;
       border: 1px solid #ccc;
       border-radius: 4px;
    
       &:focus {
         border-color: #007bff;
       }
     }
    
     .task-list {
       list-style-type: none;
       padding: 0;
       margin: 0;
     }
    
     .task-item {
       display: flex;
       align-items: center;
       padding: 10px;
       border-bottom: 1px solid #f0f0f0;
       transition: background-color 0.3s;
     }
    
     .task-item:hover {
       background-color: #f9f9f9;
     }
    
     input[type="checkbox"] {
       margin-right: 10px;
     }
    
     span {
       flex-grow: 1;
       padding: 5px;
       cursor: pointer;
     }
    
     .edit-input {
       flex-grow: 1;
       padding: 5px;
       border: 1px solid #ccc;
       border-radius: 4px;
     }
    
     .delete-button {
       background-color: #dc3545;
       color: white;
       border: none;
       border-radius: 4px;
       padding: 5px 10px;
       cursor: pointer;
       transition: background-color 0.3s;
     }
    
     .delete-button:hover {
       background-color: #c82333;
     }
    
  7. Summary

    We created TaskService to manage tasks.

    We created TaskForm component to have ability to add tasks.

    We created TaskList component to render all tasks.

    Now we should to use the component in app-component.

    Open app.component.html and add next line:

     <div class="todo-container">
       <app-task-form></app-task-form>
       <app-task-list></app-task-list>
     </div>
    

    Add app.component.scss file and add:

     .todo-container {
       background-color: white;
       padding: 20px;
       border-radius: 8px;
       box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
       width: 400px;
       margin: 20px;
       animation: fadeIn 0.5s ease-in-out;
     }
    
  8. Add animation

    Let's add some animation to the task list. Open app.config.ts and add BrowserAnimationsModule:

     import { ApplicationConfig, importProvidersFrom, provideZoneChangeDetection } from '@angular/core';
     import { provideRouter } from '@angular/router';
    
     import { routes } from './app.routes';
     import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
    
     export const appConfig: ApplicationConfig = {
       providers: [
         provideZoneChangeDetection({ eventCoalescing: true }),
         provideRouter(routes),
         importProvidersFrom(BrowserAnimationsModule)
       ]
     };
    

    Then open task-list.component.ts and define the animation in the @Component decorator:

     @Component({
       selector: 'app-task-list',
       standalone: true,
       imports: [
         FormsModule,
         JsonPipe,
         AsyncPipe,
       ],
       templateUrl: './task-list.component.html',
       styleUrl: './task-list.component.scss',
       animations: [
         trigger('taskAnimation', [
           transition(':enter', [
             animate('0.6s ease-out', keyframes([
               style({ opacity: 0, transform: 'translateY(-20px)', offset: 0 }),
               style({ opacity: 0.5, transform: 'translateY(10px)', offset: 0.3 }),
               style({ opacity: 1, transform: 'translateY(0)', offset: 1.0 })
             ]))
           ]),
           transition(':leave', [
             animate('0.5s ease-in', keyframes([
               style({ opacity: 1, transform: 'translateX(0)', offset: 0 }),
               style({ opacity: 0.5, transform: 'translateX(-10px)', offset: 0.3 }),
               style({ opacity: 0, transform: 'translateX(100%)', offset: 1.0 })
             ]))
           ])
         ])
       ],
       changeDetection: ChangeDetectionStrategy.OnPush,
     })
    

    Finally, we can just add this to the li element in the template:

     <li class="task-item" @taskAnimation>
    
  9. Let's run ToDo list app

    In the terminal, type:

     npm run start
    

Open your browser and go to http://localhost:4200.

You can see:

0
Subscribe to my newsletter

Read articles from John directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

John
John