Angular 18 and Signals: How to Build a Simple Todo List App
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.
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
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.
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
Create a model
In folder
src/app/features/task/models
create file with nametask.model.ts
.Define
Task
interface andTaskStatus
enum:export interface Task { id: number; title: string; completed: boolean; } export enum TaskStatus { All = 'All', Active = 'Active', Completed = 'Completed', }
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 ) ); } }
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; }
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; }
Add animation
Let's add some animation to the task list. Open
app.config.ts
and addBrowserAnimationsModule
: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>
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:
Subscribe to my newsletter
Read articles from John directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by