Criando um CRUD com Angular: Observables + Signals

Lucas Peixoto
23 min readJul 5, 2023

--

Introdução

Signals é uma nova feature disponível no Angular a partir da versão 16 lançado em maio de 2023. Na comunidade muitas discussões a respeito desta nova feature estão acontecendo, mas afinal, o que é um Signal ? Um signal é um tipo especial de variável, que além de possuir um valor, emite uma notificação, ou seja, notifica quando este valor é alterado (reativo). Em resumo, podemos dizer que um signal:

  • É uma variável + notificação de mudanças;
  • É reativo;
  • É síncrono;
  • Sempre possui um valor;
  • Em aplicações Angular pode ser utilizado em conjunto com observables do RxJS e não como substituto.

Contextualização

Se olharmos as dependências de um projeto angular, vamos ver que existe uma ferramenta chamada Zone.js, que auxilia o framework no processo de detecção de mudanças, ou seja, na identificação de alterações para informar que era precisa uma atualização nos templates de forma e refletir os novos valores.

Por mais que funcionasse muito bem, Zone.js não possibilita a identificação específica de onde a mudança acontece, precisando desta forma, atualizar toda a árvore de componentes da aplicação. Com o Signals, será possível uma maior granularidade nestas detecções de mudanças, ou seja, aumentar a eficiência no processo.

Neste artigo vamos entender na prática os conceitos por trás dos signals através de um crud completo. Além de entender os conceitos vamos verificar situações especificas para melhor utilizar cada funcionalidade nova por trás dos signals.

Com os signals conseguimos uma forma mais simples e eficiente de gerenciar estado em aplicações angular, neste exemplo prático vamos aprender diversas funcionalidades que nos auxiliam no gerenciamento de estado utilizando signals como, por exemplo: signals, computed, update, set, toSignal, toObservable e effect.

Na prática

Vamos começar criando um novo projeto na versão 16 do angular.

ng new signals-crud-example --standalone

Na sequência vamos instalar o angular material para facilitar a construção de alguns componentes (Obs: Na instalação do material o tema Purple/Green foi escolhido).

ng add @angular/material

Para simular situações mais próximas do que enfrentamos no dia a dia, vamos instalar também o json-server, que vai nos possibilitar a criação de um servidor local para realizarmos as operações de GET, POST, PUT e DELETE, armazenado os registros num arquivo JSON.

npm install json-server --save-dev

Nosso crud vai ser uma versão mais avançada de um todo app, vamos possuir uma lista de usuários (users) e uma lista de tarefas (tasks), onde cada tarefa é associada a um usuário via um id de referência (userId).

Vamos definir nossa base do json-server com a criação do arquivo db.json, na raiz do projeto.

Estrura do projeto com o arquivo db.json

Vamos agora criar alguns registros para usuários e tarefas no formato json dentro do nosso arquivo db.json.

{
"users": [
{
"id": 1,
"name": "Luke Skywalker",
"email": "luke_skywalker@email.com",
"gender": "M"
},
{
"id": 2,
"name": "Leia Organa",
"email": "leia@example.com",
"gender": "F"
}
],
"tasks": [
{
"id": 1,
"name": "Destruir estrela da morte",
"description": "Destruir a estrela da morte para acabar com o Império",
"userId": 1,
"completed": false
},
{
"id": 2,
"name": "Desafiar Darth Vader",
"description": "Enfrentar Darth Vader e derrota-lo",
"userId": 1,
"completed": false
},
{
"id": 3,
"name": "Motivar resistência",
"description": "Não deixar a resistência perder a motivação na luta contra o império",
"userId": 2,
"completed": false
}
]
}

Para verificar nosso servidor em funcionamento, vamos criar em nosso package.json o comando “server” com valor “json-server — watch db.json”.

{
"name": "signals-crud-example",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"server": "json-server --watch db.json",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^16.0.0",
"@angular/cdk": "^16.1.3",
"@angular/common": "^16.0.0",
"@angular/compiler": "^16.0.0",
"@angular/core": "^16.0.0",
"@angular/forms": "^16.0.0",
"@angular/material": "^16.1.3",
"@angular/platform-browser": "^16.0.0",
"@angular/platform-browser-dynamic": "^16.0.0",
"@angular/router": "^16.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.13.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^16.0.0",
"@angular/cli": "~16.0.0",
"@angular/compiler-cli": "^16.0.0",
"@types/jasmine": "~4.3.0",
"jasmine-core": "~4.6.0",
"json-server": "^0.17.3",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.0.0",
"typescript": "~5.0.2"
}
}

Para verificar o servidor rodando, vamos executar o comando server.

npm run server

Se tudo der certo vamos verificar o servidor rodando na porta 3000, onde conseguimos acessar via GET os usuários (http://localhost:3000/users) e as tarefas (http://localhost:3000/tasks). Como nossas tarefas são associadas a usuários através do userId, podemos com o json-server acessar as tarefas de um usuário específico através da url http://localhost:3000/tasks?userId=1.

Com o servidor pronto, podemos iniciar de fato nossa implementação. Para começar, vamos criar as interfaces correspondentes aos usuários (src/user/user.model.ts) e tarefas (src/task/task.model.ts).

// src/task/task.model.ts
export interface Task {
id?: number;
name: string;
description: string;
completed: boolean;
userId: number;
}
import { Task } from "../task/task.model";

// src/user/user.model.ts
export interface User {
id: number;
name: string;
email: string;
gender: 'M' | 'F';
tasks: Task[];
}

Para melhor visualização dos componentes do nosso projeto, vamos definir alguns estilos globais em styles.scss.

:root {
--white: #e7e7e7;
--black: #2e2e2e;
}

* {
padding: 0;
margin: 0;
}

*,
*::before,
*::after {
box-sizing: inherit;
}

html {
box-sizing: border-box;
font-size: 62.5%;
}

body {
background-color: var(--black);
font-family: Roboto, sans-serif;
font-weight: 400;
line-height: 1.6;
color: var(--white);
}

mat-divider {
margin: 1rem 0;
width: 100%;
}

No componente principal (app.component.html), vamos criar uma navbar para facilitar roteamento, já que vamos possuir uma tela de acesso à lista de usuário e uma tela de acesso às tarefas de um usuário específico.


<section>
<mat-toolbar color="primary">
<span routerLink="/">Signals Crud</span>
</mat-toolbar>

<router-outlet />
</section>

Como nosso projeto foi criado com standalone components, vamos precisar importar alguns módulos dentro de app.component.ts.

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { HttpClientModule } from '@angular/common/http';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatToolbarModule } from '@angular/material/toolbar';

@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, MatToolbarModule],
template: `
<section>
<mat-toolbar color="primary">
<span routerLink="/">Signals Crud</span>
</mat-toolbar>

<router-outlet />
</section>
`,
styles: [
`
span {
cursor: pointer;
}
mat-toolbar {
justify-content: space-between;
}
`,
],
})
export class AppComponent {}

Podemos iniciar a aplicação e verificar se está tudo rodando corretamente.

npm start
Visualização da aplicação rodando no browser (http://localhost:4200/)

Abaixo no nosso navbar em app.component.html, incluímos o <router-outlet />, pois neste local vamos renderizar dinamicamente a lista de usuários ou a lista de tarefas com ajuda do módulo de roteamento do angular.

Vamos então definir o componente inicial (rota raiz ‘/’) e estrutura o arquivo de rotas.

ng g c user/components/users-list --skip-tests --standalone

Agora vamos associar este componente a rota raiz dentro do arquivo src/app/app.routes.ts.

import { Routes } from '@angular/router';
import { UsersListComponent } from './user/components/users-list/users-list.component';

export const routes: Routes = [
{ path: "", pathMatch: 'full', component: UsersListComponent },
];

Salvando as alterações já podemos verificar o conteúdo de UsersListComponent renderizado logo abaixo na nossa navbar.

Antes de construir a tabela que vai exibir os usuários, vamos criar o serviço responsável por implementar nossos métodos para gerenciamento do estado de usuários com os signals.

ng g s user/user --skip-tests

Agora no user.service.ts, vamos declarar alguns atributos.

import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { User } from './user.model';
import { toSignal } from '@angular/core/rxjs-interop';

@Injectable({
providedIn: 'root'
})
export class UserService {

public http = inject(HttpClient);

public userUrl = 'http://localhost:3000/users';

private users$ = this.http.get<User[]>(this.userUrl);

public users = toSignal(this.users$, { initialValue: [] as User[] });
}

Aqui definimos nossa userUrl, que é a url para acessar via GET o array de usuários, na sequência criamos um user$, que é um Observable<User[]>. Poderíamos acessar a listagem de usuários no template através do AsyncPipe, porem, com signals, podemos por meio de um observable, criar um signal utilizando o método toSignal, que está em um novo pacote da versão 16 do angular (@angular/core/rxjs-interop).

O toSignal é uma ótima alternativa para situações onde precisamos apenas acessar este array de usuários. O método toSignal recebe um observable como primeiro parâmetro, e aqui passamos no segundo parâmetro um objeto de configuração com a propriedade initialValue definida como um array vazio, caso não fizéssemos isso, nosso signal seria do tipo User | undefined.

Vamos agora construir nossa tabela e entender como acessamos o array de usuários que está armazenado dentro do nosso signal users via UserService.

No UsersListComponent, vamos utilizar inline template e inline styles para facilitar as exibições.

import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UserService } from '../../user.service';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTableModule } from '@angular/material/table';

@Component({
selector: 'app-users-list',
standalone: true,
imports: [CommonModule, MatTableModule, MatButtonModule, MatIconModule],
template: `
<section class="container" *ngIf="users().length">
<div class="container__header">
<span>Users</span>
</div>
<table mat-table [dataSource]="users()" class="mat-elevation-z8">
<ng-container
[matColumnDef]="column"
*ngFor="let column of displayedColumns"
>
<th mat-header-cell *matHeaderCellDef>{{ column | titlecase }}</th>
<td mat-cell *matCellDef="let element">{{ element[column] }}</td>
</ng-container>
<ng-container matColumnDef="action">
<th mat-header-cell *matHeaderCellDef>Tarefas</th>
<td mat-cell *matCellDef="let user">
<button mat-icon-button color="accent">
<mat-icon>search</mat-icon>
</button>
</td>
</ng-container>

<tr mat-header-row *matHeaderRowDef="fullColumns"></tr>
<tr mat-row *matRowDef="let row; columns: fullColumns"></tr>
</table>
</section>
`,
styles: [
`
th,
td {
text-align: center;
}

.container {
padding: 2rem 10rem;
gap: 2rem;

display: flex;
flex-direction: column;
align-items: center;

&__header {
> span {
font-size: 2rem;
line-heith: 1rem;
}
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
text-align: center;
}
}
`,
],
})
export class UsersListComponent {
public displayedColumns = ['id', 'name', 'email', 'gender'];

public fullColumns = ['id', 'name', 'email', 'gender', 'action'];

public userService = inject(UserService);

public users = this.userService.users;
}

Aqui vemos que para acessar o valor de um signals, precisamos chamar o valor com users(), ao fazer isso acessamos o valor, ou seja, esse template passa a receber de forma reativa as alterações que ocorrerem no array, ou seja, sempre que um registro de usuário for alterado, adicionado ou deletado, o componente será notificado e a tela vai ser atualizada de forma reativa😨.

Podemos ver agora a listagem dos nossos usuários sendo exibida na tabela.

Visualização da aplicação rodando no browser (http://localhost:4200/)

Vamos imaginar agora, que na listagem de usuários queremos exibir o total de usuários listados, desta forma:

Neste caso é bem simples, podemos fazer users().length e obter o valor, porem, em alguns casos, pode ser necessário utilizar uma informação, com seu valor dependente de outro, se este outro valor for um signal, podemos utilizar o método computed.

O método computed, em resumo, cria um signal que é dependente de outro, ele recebe uma função por parâmetro que retorna um valor conforme a lógica que passamos. Nesta lógica podemos acessar outros signals, caso façamos isso, sempre que este signal emitir um novo valor, o resultado do nosso computed será alterado e o mesmo também emitirá um novo valor.

Vamos criar então um signal via computed que vai ser a contagem total de usuários.

Em UserService:

import { HttpClient } from '@angular/common/http';
import { Injectable, computed, inject } from '@angular/core';
import { User } from './user.model';
import { toSignal } from '@angular/core/rxjs-interop';

@Injectable({
providedIn: 'root'
})
export class UserService {

public http = inject(HttpClient);

public userUrl = 'http://localhost:3000/users';

private users$ = this.http.get<User[]>(this.userUrl);

public users = toSignal(this.users$, { initialValue: [] as User[] });

public totalUsersCount = computed(() => this.users().length)
}

Podemos agora acessar o valor no nosso template.

import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UserService } from '../../user.service';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTableModule } from '@angular/material/table';

@Component({
selector: 'app-users-list',
standalone: true,
imports: [CommonModule, MatTableModule, MatButtonModule, MatIconModule],
template: `
<section class="container" *ngIf="users().length">
<div class="container__header">
<span>Users ({{totalUsersCount()}})</span>
</div>
<table mat-table [dataSource]="users()" class="mat-elevation-z8">
<ng-container
[matColumnDef]="column"
*ngFor="let column of displayedColumns"
>
<th mat-header-cell *matHeaderCellDef>{{ column | titlecase }}</th>
<td mat-cell *matCellDef="let element">{{ element[column] }}</td>
</ng-container>
<ng-container matColumnDef="action">
<th mat-header-cell *matHeaderCellDef>Tarefas</th>
<td mat-cell *matCellDef="let user">
<button mat-icon-button color="accent">
<mat-icon>search</mat-icon>
</button>
</td>
</ng-container>

<tr mat-header-row *matHeaderRowDef="fullColumns"></tr>
<tr mat-row *matRowDef="let row; columns: fullColumns"></tr>
</table>
</section>
`,
styles: [
`
th,
td {
text-align: center;
}

.container {
padding: 2rem 10rem;
gap: 2rem;

display: flex;
flex-direction: column;
align-items: center;

&__header {
> span {
font-size: 2rem;
line-heith: 1rem;
}
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
text-align: center;
}
}
`,
],
})
export class UsersListComponent {
public displayedColumns = ['id', 'name', 'email', 'gender'];

public fullColumns = ['id', 'name', 'email', 'gender', 'action'];

public userService = inject(UserService);

public users = this.userService.users;

public totalUsersCount = this.userService.totalUsersCount;
}

Vamos agora criar o componente que vai exibir a listagem de tarefas para determinado usuário.

ng g c task/components/task-details --skip-tests --standalone

Já vamos criar também o serviço para realizar o gerenciamento de estado das tarefas.

ng g s task/task --skip-tests

Agora vamos colocar uma nova rota em app.routes.ts que vai chamar o nosso TaskDetailsComponet, que vai receber o id do usuário como parâmetro, já que precisamos desta informação para implementar o CRUD de tarefas.

import { Routes } from '@angular/router';
import { UsersListComponent } from './user/components/users-list/users-list.component';
import { TaskDetailsComponent } from './task/components/task-details/task-details.component';

export const routes: Routes = [
{ path: "", pathMatch: 'full', component: UsersListComponent },
{ path: "tasks/:id", component: TaskDetailsComponent },
];

Agora podemos voltar em UsersListComponent para colocar a ação de redirecionamento para TaskDetailsComponent passando o userId do usuário. Poderíamos realizar o simples routerLink para redirecionar, porem, pode ser necessário recuperar o userId do usuário selecionado em outras estruturas, como, por exemplo, um http GET dentro do serviço para recuperar as tarefas para determinado usuário. Sendo assim, vamos criar um signal em UserService para armazenar o userId de usuário selecionado.

@Injectable()
export class UserService {

public http = inject(HttpClient);

public userUrl = 'http://localhost:3000/users';

private users$ = this.http.get<User[]>(this.userUrl);

public users = toSignal(this.users$, { initialValue: [] as User[] });

public totalUsersCount = computed(() => this.users().length);

public selectedUserId = signal(0);

public setSelectedUserId(id: number): void {
this.selectedUserId.set(id);
}

}

Observação: Os nossos serviços não serão marcados com providedIn: ‘root’, ao invés disso vamos colocar todos dentro do array providers do app.component

Aqui além do signal selectedUserId, criamos também o método setSelectedUserId que vai receber o id se chamar o método set, que é um método disponível em todos os signals que vai substituir o valor atual deste signal e atribuir um novo, e junto desta alteração de valor, emitirá a notificação de mudança.

Vamos voltar agora em UsersListComponent e criar o método que vai chamar o userService.setSelectedUserId e navegar para TaskDetailsComponent.

export class UsersListComponent {
public displayedColumns = ['id', 'name', 'email', 'gender'];

public fullColumns = ['id', 'name', 'email', 'gender', 'action'];

public userService = inject(UserService);

public router = inject(Router);

public users = this.userService.users;

public totalUsersCount = this.userService.totalUsersCount;

public setSelectedUserId(id: number): void {
this.userService.setSelectedUserId(id);

this.router.navigateByUrl(`tasks/${id}`);
}
}

Feito isto basta associar o click do ícone de tarefa ao método setSelectedUserId.

@Component({
selector: 'app-users-list',
standalone: true,
imports: [CommonModule, MatTableModule, MatButtonModule, MatIconModule],
template: `
<section class="container" *ngIf="users().length">
<div class="container__header">
<span>Users ({{ totalUsersCount() }})</span>
</div>
<table mat-table [dataSource]="users()" class="mat-elevation-z8">
<ng-container
[matColumnDef]="column"
*ngFor="let column of displayedColumns"
>
<th mat-header-cell *matHeaderCellDef>{{ column | titlecase }}</th>
<td mat-cell *matCellDef="let element">{{ element[column] }}</td>
</ng-container>
<ng-container matColumnDef="action">
<th mat-header-cell *matHeaderCellDef>Tarefas</th>
<td mat-cell *matCellDef="let user">
<button
mat-icon-button
color="accent"
(click)="setSelectedUserId(user.id)"
>
<mat-icon>search</mat-icon>
</button>
</td>
</ng-container>

<tr mat-header-row *matHeaderRowDef="fullColumns"></tr>
<tr mat-row *matRowDef="let row; columns: fullColumns"></tr>
</table>
</section>
`,
styles: [
`
th,
td {
text-align: center;
}

.container {
padding: 2rem 10rem;
gap: 2rem;

display: flex;
flex-direction: column;
align-items: center;

&__header {
> span {
font-size: 2rem;
line-heith: 1rem;
}
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
text-align: center;
}
}
`,
],
})
export class UsersListComponent {
public displayedColumns = ['id', 'name', 'email', 'gender'];

public fullColumns = ['id', 'name', 'email', 'gender', 'action'];

public userService = inject(UserService);

public router = inject(Router);

public users = this.userService.users;

public totalUsersCount = this.userService.totalUsersCount;

public setSelectedUserId(id: number): void {
this.userService.setSelectedUserId(id);

this.router.navigateByUrl(`tasks/${id}`);
}
}

Agora estamos prontos para iniciar a construção ta nossa tabela com as tarefes do usuário selecionado.

A primeira coisa que precisamos fazer é garantir a persistência do userId de usuário selecionado. O userId está armazenado dentro do nosso signal selectedUserId, porem, caso ocorra um refresh na página TaskDetailsComponent, esse userId será perdido, ou seja, dentro do ngOnInit deste componente nós vamos recuperar o userId via rota e atualizar o signal selectedUserId.

import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { UserService } from 'src/app/user/user.service';

@Component({
selector: 'app-task-details',
standalone: true,
imports: [
CommonModule,
RouterLink
],
template: ``,
styles: [``],
})
export class TaskDetailsComponent implements OnInit {

public selecterUserId!: number;

public userService = inject(UserService);

public route = inject(ActivatedRoute);

public router = inject(Router);

public ngOnInit(): void {
this.selecterUserId = +this.route.snapshot.paramMap.get('id')!;

if (this.selecterUserId) {
this.userService.setSelectedUserId(this.selecterUserId);
} else {
this.router.navigateByUrl('/');
}
}
}

Em resumo, optamos em obter o userId de forma síncrona com o route.snapshot.paramMap.get(‘id’) do ActivatedRoute e chamar o método setSelectedUserId para atualizar o valor.

Diferente de como fizemos com o GET de usuários, para obter a listagem de tarefas, precisamos passar o userId correspondente. Aqui precisamos realizar um novo GET sempre que o selectedUserId for alterado, porem este valor é um signal, e não um observable, desta forma, podemos utilizar o método toObservable, que assim como toSignal está dentro do pacote @angular/core/rxjs-interop.

import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { UserService } from '../user/user.service';
import { Task } from './task.model';
import { toObservable } from '@angular/core/rxjs-interop';
import { switchMap, tap } from 'rxjs';

@Injectable()
export class TaskService {

public http = inject(HttpClient);

public userService = inject(UserService);

public usersUrl = 'http://localhost:3000/users';

public tasksUrl = 'http://localhost:3000/tasks';

public userTasks = signal<Task[]>([]);

private userTasks$ = toObservable(this.userService.selectedUserId).pipe(
switchMap((userId) =>
this.http
.get<Task[]>(`${this.tasksUrl}?userId=${userId}`)
.pipe(tap((tasks) => {
this.userTasks.set(tasks);

}))
)
);
public readOnlyUserTasks = toSignal(this.userTasks$, {
initialValue: [] as Task[],
});
}

Vamos entender o que foi feito aqui. Para recuperar a listagem de tarefas de um determinado usuário, precisamos acessar o valor do userId (this.userService.selectedUserId) e utiliza-lo na chamada GET para a url http://localhost:3000/tasks?userId=userId.

A chamada http.get é um observable e o this.userService.selectedUserId um signal, para resolver isso transformamos este signal em um observable com o método toObservable e com a ajuda do operador switchMap, conseguimos realizar a chamada da api no mesmo contexto que recuperamos o userId.

Para finalizar, podemos utilizar o operador tap do RxJS e o operador set para atualizar a listagem de tarefas daquele usuário em questão.

Por fim, para a chamada GET da listagem de tarefas acontecer, precisamos de algum signal derivado do nosso observable (userTasks$), para isso, criamos o readOnlyUserTasks a partir de userTasks$ com ajuda o metodo toSignal.

Este signal assim como userTasks tambem vai receber atualizações em seus valores, porem este é um signal do tipo Signal, diferente do userTasks, que é um WritableSignal, ou seja, o readOnlyUserTasks não permite alteração nos valores.

Agora, sempre que o selectedUserId alterar, essa chamada será realizada novamente e iremos atualizar o valor do userTasks.

Com isto feito, podemos construir nossa listagem de tarefas em TaskDetailsComponent.

import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { UserService } from '../../../user/user.service';
import { TaskService } from '../../task.service';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';

@Component({
selector: 'app-task-details',
standalone: true,
imports: [
CommonModule,
RouterLink,
MatTableModule,
MatButtonModule,
MatIconModule,
MatDividerModule,
MatTooltipModule,
MatDialogModule,
],
template: `
<section class="container">
<div class="container__header">
<span>Tasks</span>
</div>

<table mat-table [dataSource]="userTasks()" class="mat-elevation-z8">
<ng-container
[matColumnDef]="column"
*ngFor="let column of displayedColumns"
>
<th mat-header-cell *matHeaderCellDef>{{ column | titlecase }}</th>
<ng-container [ngSwitch]="column" ]>
<ng-container *ngSwitchCase="'completed'">
<td mat-cell *matCellDef="let element">
<mat-icon *ngIf="element[column] == true" color="accent"
>done</mat-icon
>
<mat-icon *ngIf="element[column] == false" color="warn"
>schedule</mat-icon
>
</td></ng-container
>

<ng-container *ngSwitchDefault
><td mat-cell *matCellDef="let element">
{{ element[column] }}
</td></ng-container
>
</ng-container>
</ng-container>

<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>

<mat-divider></mat-divider>

<div class="container__actions">
<button mat-raised-button color="warn" routerLink="/">Back</button>
</div>
</section>
`,
styles: [
`
th,
td {
text-align: center;
}
.container {
padding: 2rem 10rem;
gap: 2rem;

display: flex;
flex-direction: column;
align-items: center;

&__header {
> span {
font-size: 2rem;
line-heith: 1rem;
}

width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
text-align: center;
}

&__actions {
width: 100%;
display: flex;
justify-content: center;
}
}
`,
],
})
export class TaskDetailsComponent implements OnInit {
public displayedColumns = ['id', 'name', 'description', 'completed'];

public selecterUserId!: number;

public userService = inject(UserService);

public taskService = inject(TaskService);

public route = inject(ActivatedRoute);

public router = inject(Router);

public userTasks = this.taskService.userTasks;

public ngOnInit(): void {
this.selecterUserId = +this.route.snapshot.paramMap.get('id')!;

if (this.selecterUserId) {
this.userService.setSelectedUserId(this.selecterUserId);
} else {
this.router.navigateByUrl('/');
}

}
}

Vamos agora implementar a funcionalidade de edição das tarefas, para isso iremos incluir uma nova coluna com um ícone para clicar e marcar uma tarefa como finalizada (completed = true).

Primeiro criamos um novo array de colunas com uma coluna nova, ‘status’.

export class TaskDetailsComponent implements OnInit {
public displayedColumns = ['id', 'name', 'description', 'completed'];

public fullColumns = [...this.displayedColumns, 'status'];

...
}

Agora incluímos a nova coluna no template.

<section class="container">
<div class="container__header">
<span>Tasks</span>
</div>

<table mat-table [dataSource]="userTasks()" class="mat-elevation-z8">
<ng-container
[matColumnDef]="column"
*ngFor="let column of displayedColumns"
>
<th mat-header-cell *matHeaderCellDef>{{ column | titlecase }}</th>
<ng-container [ngSwitch]="column" ]>
<ng-container *ngSwitchCase="'completed'">
<td mat-cell *matCellDef="let element">
<mat-icon *ngIf="element[column] == true" color="accent"
>done</mat-icon
>
<mat-icon *ngIf="element[column] == false" color="warn"
>schedule</mat-icon
>
</td></ng-container
>

<ng-container *ngSwitchDefault
><td mat-cell *matCellDef="let element">
{{ element[column] }}
</td></ng-container
>
</ng-container>
</ng-container>

<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let task">
<button
mat-icon-button
[disabled]="task.completed"
matTooltip="Click to complete task"
color="accent"
>
<mat-icon aria-label="Edit">done_all</mat-icon>
</button>
</td>
</ng-container>

<tr mat-header-row *matHeaderRowDef="fullColumns"></tr>
<tr mat-row *matRowDef="let row; columns: fullColumns"></tr>
</table>

<mat-divider></mat-divider>

<div class="container__actions">
<button mat-raised-button color="warn" routerLink="/">Back</button>
</div>
</section>

Nossa tabela ficou assim:

Exibição da rota task/1 com a listagem de tarefas para usuário de id 1.

Agora vamos criar em TaskDetailsComponent o método updteTaskStatus, que vai realizar um HTTP put para a rota /tasks/userId enviando como payload a tarefa selecionada com o parâmetro completed igual a true.

public updteTaskStatus(task: Task): void {

const completedTask = {
...task,
completed: true,
};

this.http
.put(this.tasksUrl + '/' + task.id, completedTask)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.userTasks.update((tasks) =>
tasks.map((_task) => (_task.id === task.id ? completedTask : _task))
);
},
//! Error handling
});
}

Aqui realizamos um HTTP put da forma que sempre fizemos, porem, uma vez que a operação é realizada com sucesso, precisamos alterar a tarefa selecionada dentro do nosso signals de tarefas (userTaks). Para isso, podemos utilizar o update, um método dos signals ideal para esta situação, pois o update não substitui o array inteiro, ele realiza uma mudança interna em algum dos registros.

Ao realizarmos update((tasks) => tasks.map((_task) => (_task.id === task.id ? completedTask : _task))), vamos atualizar o valor da lista de tarefas alterando a tarefa encontrada para completedTaks.

Aqui mais uma vez, ao realizar uma alteração no signals userTasks, todos os templates que chamam o valor serão atualizados, sendo assim, nosso valor da coluna completed é alterado de forma reativa.

Mudança do status completed da tarefa ao chamar o método updteTaskStatus

Observação: Como realizamos um subscribe em um observable, é uma boa prática realizar este unsubscribe em algum momento. No exemplo o unsubscribe foi realizado com o operador takeUntilDestroyed em conjunto com o DestroyRef, que vai realizar automaticamente o unsubscribe no momento de destruição deste componente.

Vamos agora implementar a funcionalidade para deletar tarefas. Assim como fizemos para edição do status, vamos criar uma nova coluna ( ‘delete’) no template.

public displayedColumns = ['id', 'name', 'description', 'completed'];

public fullColumns = [...this.displayedColumns, 'status', 'delete'];

e na sequência a coluna na tabela.

<ng-container matColumnDef="delete">
<th mat-header-cell *matHeaderCellDef>Delete Task</th>
<td mat-cell *matCellDef="let task">
<button
mat-icon-button
(click)="deleteTask(task.id)"
matTooltip="Click to delete task"
color="warn"
>
<mat-icon aria-label="Delete">delete</mat-icon>
</button>
</td>
</ng-container>

Agora criamos o método para excluir a tarefa selecionada.

public deleteTask(taskId: number): void {
this.http
.delete(this.tasksUrl + '/' + taskId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.taskService.userTasks.update((tasks) =>
tasks.filter((task) => task.id !== taskId)
);
},
//! Error handling
});
}

Juntando tudo, nosso componente ficou da seguinte forma:

import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { UserService } from '../../../user/user.service';
import { TaskService } from '../../task.service';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { HttpClient } from '@angular/common/http';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Task } from '../../task.model';

@Component({
selector: 'app-task-details',
standalone: true,
imports: [
CommonModule,
RouterLink,
MatTableModule,
MatButtonModule,
MatIconModule,
MatDividerModule,
MatTooltipModule,
MatDialogModule,
],
template: `
<section class="container">
<div class="container__header">
<span>Tasks</span>
</div>

<table mat-table [dataSource]="userTasks()" class="mat-elevation-z8">
<ng-container
[matColumnDef]="column"
*ngFor="let column of displayedColumns"
>
<th mat-header-cell *matHeaderCellDef>{{ column | titlecase }}</th>
<ng-container [ngSwitch]="column" ]>
<ng-container *ngSwitchCase="'completed'">
<td mat-cell *matCellDef="let element">
<mat-icon *ngIf="element[column] == true" color="accent"
>done</mat-icon
>
<mat-icon *ngIf="element[column] == false" color="warn"
>schedule</mat-icon
>
</td></ng-container
>

<ng-container *ngSwitchDefault
><td mat-cell *matCellDef="let element">
{{ element[column] }}
</td></ng-container
>
</ng-container>
</ng-container>

<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let task">
<button
mat-icon-button
[disabled]="task.completed"
matTooltip="Click to complete task"
color="accent"
(click)="updteTaskStatus(task)"
>
<mat-icon aria-label="Edit">done_all</mat-icon>
</button>
</td>
</ng-container>

<ng-container matColumnDef="delete">
<th mat-header-cell *matHeaderCellDef>Delete Task</th>
<td mat-cell *matCellDef="let task">
<button
mat-icon-button
(click)="deleteTask(task.id)"
matTooltip="Click to delete task"
color="warn"
>
<mat-icon aria-label="Delete">delete</mat-icon>
</button>
</td>
</ng-container>

<tr mat-header-row *matHeaderRowDef="fullColumns"></tr>
<tr mat-row *matRowDef="let row; columns: fullColumns"></tr>
</table>

<mat-divider></mat-divider>

<div class="container__actions">
<button mat-raised-button color="warn" routerLink="/">Back</button>
</div>
</section>
`,
styles: [
`
th,
td {
text-align: center;
}
.container {
padding: 2rem 10rem;
gap: 2rem;

display: flex;
flex-direction: column;
align-items: center;

&__header {
> span {
font-size: 2rem;
line-heith: 1rem;
}

width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
text-align: center;
}

&__actions {
width: 100%;
display: flex;
justify-content: center;
}
}
`,
],
})
export class TaskDetailsComponent implements OnInit {
public displayedColumns = ['id', 'name', 'description', 'completed'];

public fullColumns = [...this.displayedColumns, 'status', 'delete'];

public selectedUserId!: number;

public tasksUrl = 'http://localhost:3000/tasks';

public userService = inject(UserService);

public taskService = inject(TaskService);

public route = inject(ActivatedRoute);

public router = inject(Router);

public http = inject(HttpClient);

public destroyRef = inject(DestroyRef);

public userTasks = this.taskService.userTasks;

public ngOnInit(): void {
this.selectedUserId = +this.route.snapshot.paramMap.get('id')!;

if (this.selectedUserId) {
this.userService.setSelectedUserId(this.selectedUserId);
} else {
this.router.navigateByUrl('/');
}
}

public updteTaskStatus(task: Task): void {
const completedTask = {
...task,
completed: true,
};

this.http
.put(this.tasksUrl + '/' + task.id, completedTask)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.userTasks.update((tasks) =>
tasks.map((_task) => (_task.id === task.id ? completedTask : _task))
);
},
//! Error handling
});
}

public deleteTask(taskId: number): void {
this.http
.delete(this.tasksUrl + '/' + taskId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.taskService.userTasks.update((tasks) =>
tasks.filter((task) => task.id !== taskId)
);
},
//! Error handling
});
}
}

Para nosso signal de tarefas ser atualizado, utilizamos o método update, que recebe uma função onde temos acesso ao valor atual do signal, desta forma podemos realizar um filter no array retornando todas as tarefas com exceção da que excluímos.

Para finalizar, vamos criar um botão acima da tabela para adicionar uma nova tarefa e também o método addNewTask().

template: `
<section class="container">
<div class="container__header">
<span>Tasks</span>
<button mat-fab color="accent" (click)="addNewTask()">
<mat-icon>add_circle</mat-icon>
</button>
</div>
public addNewTask(): void {
const newTask = {
name: "New task",
description: "New task description",
completed: false
}

this.http
.post(this.tasksUrl, newTask)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (response) => {
const responseTaks = response as Task;
this.userTasks.update((task) => [...task, responseTaks]);
},
//! Error handling
});
}

Resultado final em TaskDetailComponent:

import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { UserService } from '../../../user/user.service';
import { TaskService } from '../../task.service';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { HttpClient } from '@angular/common/http';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Task } from '../../task.model';

@Component({
selector: 'app-task-details',
standalone: true,
imports: [
CommonModule,
RouterLink,
MatTableModule,
MatButtonModule,
MatIconModule,
MatDividerModule,
MatTooltipModule,
MatDialogModule,
],
template: `
<section class="container">
<div class="container__header">
<span>Tasks</span>
<button mat-fab color="accent" (click)="addNewTask()">
<mat-icon>add_circle</mat-icon>
</button>
</div>

<table mat-table [dataSource]="userTasks()" class="mat-elevation-z8">
<ng-container
[matColumnDef]="column"
*ngFor="let column of displayedColumns"
>
<th mat-header-cell *matHeaderCellDef>{{ column | titlecase }}</th>
<ng-container [ngSwitch]="column" ]>
<ng-container *ngSwitchCase="'completed'">
<td mat-cell *matCellDef="let element">
<mat-icon *ngIf="element[column] == true" color="accent"
>done</mat-icon
>
<mat-icon *ngIf="element[column] == false" color="warn"
>schedule</mat-icon
>
</td></ng-container
>

<ng-container *ngSwitchDefault
><td mat-cell *matCellDef="let element">
{{ element[column] }}
</td></ng-container
>
</ng-container>
</ng-container>

<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let task">
<button
mat-icon-button
[disabled]="task.completed"
matTooltip="Click to complete task"
color="accent"
(click)="updteTaskStatus(task)"
>
<mat-icon aria-label="Edit">done_all</mat-icon>
</button>
</td>
</ng-container>

<ng-container matColumnDef="delete">
<th mat-header-cell *matHeaderCellDef>Delete Task</th>
<td mat-cell *matCellDef="let task">
<button
mat-icon-button
(click)="deleteTask(task.id)"
matTooltip="Click to delete task"
color="warn"
>
<mat-icon aria-label="Delete">delete</mat-icon>
</button>
</td>
</ng-container>

<tr mat-header-row *matHeaderRowDef="fullColumns"></tr>
<tr mat-row *matRowDef="let row; columns: fullColumns"></tr>
</table>

<mat-divider></mat-divider>

<div class="container__actions">
<button mat-raised-button color="warn" routerLink="/">Back</button>
</div>
</section>
`,
styles: [
`
th,
td {
text-align: center;
}
.container {
padding: 2rem 10rem;
gap: 2rem;

display: flex;
flex-direction: column;
align-items: center;

&__header {
> span {
font-size: 2rem;
line-heith: 1rem;
}

width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
text-align: center;
}

&__actions {
width: 100%;
display: flex;
justify-content: center;
}
}
`,
],
})
export class TaskDetailsComponent implements OnInit {
public displayedColumns = ['id', 'name', 'description', 'completed'];

public fullColumns = [...this.displayedColumns, 'status', 'delete'];

public selectedUserId!: number;

public tasksUrl = 'http://localhost:3000/tasks';

public userService = inject(UserService);

public taskService = inject(TaskService);

public route = inject(ActivatedRoute);

public router = inject(Router);

public http = inject(HttpClient);

public destroyRef = inject(DestroyRef);

public userTasks = this.taskService.userTasks;

public ngOnInit(): void {
this.selectedUserId = +this.route.snapshot.paramMap.get('id')!;

if (this.selectedUserId) {
this.userService.setSelectedUserId(this.selectedUserId);
} else {
this.router.navigateByUrl('/');
}
}

public updteTaskStatus(task: Task): void {
const completedTask = {
...task,
completed: true,
};

this.http
.put(this.tasksUrl + '/' + task.id, completedTask)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.userTasks.update((tasks) =>
tasks.map((_task) => (_task.id === task.id ? completedTask : _task))
);
},
//! Error handling
});
}

public deleteTask(taskId: number): void {
this.http
.delete(this.tasksUrl + '/' + taskId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.taskService.userTasks.update((tasks) =>
tasks.filter((task) => task.id !== taskId)
);
},
//! Error handling
});
}

public addNewTask(): void {
const newTask = {
name: "New task",
description: "New task description",
completed: false,
userId: 1
}

this.http
.post(this.tasksUrl, newTask)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (response) => {
const responseTaks = response as Task;
this.userTasks.update((task) => [...task, responseTaks]);
},
//! Error handling
});
}
}

Para finalizar, vamos verificar um exemplo de uso do método effect, que permite a execução de alguma lógica quando um signal emite notificação de alteração de valor.

No nosso exemplo, vamos imaginar que precisamos armazenar em localStorage a listagem de tarefas, podemos fazer isso de forma muito simples com o effect, pois sempre que a listagem alterar, nossa lógica dentro do effect será executada.

O effect para funcionar da maneira que queremos, deve ser implementado precisa ser usada dentro de algum Injection Context, como por exemplo o método construtor.

Fechando, vamos incluir nosso effect dentro do construtor de app.component.ts

import { Component, effect, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink, RouterModule, RouterOutlet } from '@angular/router';
import { MatToolbar, MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import {} from '@angular/common/http';
import { TaskService } from './features/tasks/task.service';
import { UserService } from './features/users/user.service';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';

@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, MatToolbarModule],
template: `
<section>
<mat-toolbar color="primary">
<span routerLink="/">Signals Crud</span>
</mat-toolbar>

<router-outlet />
</section>
`,
styles: [
`
span {
cursor: pointer;
}
mat-toolbar {
justify-content: space-between;
}
`,
],
providers: [
TaskService,
UserService,
{
provide: MatDialogRef,
useValue: {},
},
],
})
export class AppComponent {
public taskService = inject(TaskService);

constructor() {
effect(() => {
localStorage.setItem(
'TASKS',
JSON.stringify(this.taskService.userTasks())
);
});
}
}

O código final pode ser conferido no github: https://github.com/lucasspeixoto/signals-crud-example

Conclusão

Neste artigo podemos entender de forma prática o que são signals e como utilizar na prática em uma implementação onde combinamos signals e RxJS observables. Além disso, verificamos também como gerenciar estado com signals utilizando diversas funcionalidades como set, update, update, toSignal, toObservable e effect.

--

--

Lucas Peixoto

Sou o Lucas, desenvolvedor Web apaixonado em frontend atuando profissionalmente a 4 anos com Angular, React e diversas ferramentas do escossistema frontend.