Introdução ao Angular Signals
Ainda em developer preview, Angular Signals é um novo recurso que pode mudar o atual sistema de detecção de mudanças do Angular. Criando "pacote" ao redor de um valor e pode notificar consumidores interessados nas mudanças do valor. Conheça os motivadores e veja alguns exemplos neste post
Se você procurar informações sobre o Angular 16, que está em desenvolvimento (junho/2023), com certeza vai encontrar diversos links falando sobre o Angular Signals. Apesar de não ser um conceito novo é uma novidade no Angular e vamos falar sobre ele neste texto.
Inicialmente em developer mode, este novo recurso, signals, tem o objetivo de tornar o código mais reativo e melhorar a performance.
Angular signals are available for developer preview. They're ready for you to try, but may change before they are stable.
https://angular.io/guide/signals
Já que é um novo recurso, não temos histórico de uso em projetos Angular, então vou compartilhar um resumo sobre o signals, abordando os motivadores e funcionamento.
Detecção de mudanças
Primeiramente vamos entender um pouco sobre as motivações que levaram ao Angular Signals.
Todo desenvolvedor que já utilizou Angular sabe que existe uma sincronização entre o seu modelo de dados e o DOM, em outra palavras, quando atualizamos o valor de uma variável há um processo que atualiza este valor no template (HTML). No exemplo abaixo há uma lógica bem simples que mostra este cenário, onde ao clicar no botão "Change Text" o valor dentro do <div>
é modificado de hello
para changed
:
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
standalone: true,
imports: [CommonModule],
template: `
<div>{{ text }}</div>
<div>
<button (click)="changeText()">Change Text</button>
</div>
`,
})
export class ExampleComponent {
text = 'hello';
// Ao clicar no botão atualizamos o texto
// dentro do <div>{{ text }}></div>
changeText() {
this.text = 'changed';
}
}
No Angular esta sincronização depende do ZoneJs, que inclusive aparece como dependência no seu arquivo package.json
("zone.js": "~0.12.0"
). Basicamente ele cria uma estrutura sobre as APIs nativas do navegador para identificar quando ocorrem determinados eventos, por exemplo, um clique, setTimeouts, Promises, entre outros. Quando ele identifica um evento ele "avisa" o Angular sobre este evento e então o Angular inicia o processo de detecção de mudanças para sincronizar o modelo de dados com o DOM. Inclusive já falamos sobre isto, deixei o link abaixo.
O processo de detecção de mudanças percorre a árvore de componentes verificando quais propriedades e dados vinculados do componente foram modificados para determinar quais partes do DOM precisam ser atualizadas.
Este processo de sincronização gera um custo de desempenho visto que antes mesmo do carregamento de sua aplicação Angular, o ZoneJs precisa ser carregado e executado. Além disto, mesmo que em alguns eventos não seja necessário atualizar o DOM, o Angular verificará toda a árvore de componentes buscando por atualizações, conforme o gif acima ilustra. Nestes casos podemos avaliar o uso do OnPush
, mas ainda assim haverá uma uma busca parcial na árvore de componentes:
Agora que contextualizamos um pouco sobre o mecanismo de detecção, podemos falar do signals.
Angular Signals
No dia 15 de fevereiro de 2023 foi postado no GitHub a intenção da equipe de desenvolvedores em criar um novo mecanismo para melhorar a reatividade. Segundo o texto (trecho abaixo) a ideia é melhorar diversos pontos adotando uma solução nativa, o Angular Signals:
In a fine-grained reactive web framework, components track which parts of the application's data model they depend on, and are only synchronized with the UI when that model changes. This is fundamentally different from how Angular works today, where it uses zone.js to trigger global top-down change detection for the whole application.
We believe adding built-in reactivity to Angular unlocks many new capabilities, including:
- A clear and unified model for how data flows through an application.
- Built-in framework support for declarative derived state (a common feature request).
- Synchronizing only the parts of the UI that needs updated, at or even below the granularity of individual components.
- Significantly improved interoperability with reactive libraries such as RxJS.
- Better guardrails to avoid common pitfalls that lead to poor change detection performance and avoid common pain points such asExpressionChangedAfterItHasBeenChecked
errors.
- A viable path towards writing fully zoneless applications, eliminating the overhead, pitfalls, and quirks of zone.js.
- Simplification of many framework concepts, such as queries and lifecycle hooks.
https://github.com/angular/angular/discussions/49090
Podemos dizer que o signal "empacota" um determinado valor adicionando um comportamento reativo. A ideia é notificar que houve uma alteração em um valor para quem estiver "escutando" estas mudanças, seguindo o conceito de Producer e Consumer. Desta forma um componente pode ser "avisado" que houve uma mudança e pode atualizar o DOM, sem a necessidade de percorrer toda a árvore de componentes.
A signal is a wrapper around a value that can notify interested consumers when that value changes. Signals can contain any value, from simple primitives to complex data structures.
https://angular.io/guide/signals#what-are-signals
This will be a significant change to how Angular works, as it currently relies on zone.js to detect changes in the whole tree of components by default. Instead, with signals, the framework will only dirty-check the components that are impacted by a change, which of course makes the re-rendering process more efficient.
https://blog.ninja-squad.com/2023/04/26/angular-signals/
Utilizando o Signals
Partindo para o código, o uso do signal é bem simples, veja os dois códigos abaixo, o primeiro sem o signal
e o segundo trecho com:
Perceba que a c
é o resultado da soma de a
e b
. Quando alteramos o valor de a
podemos dizer que c
não "soube" deste novo valor, portanto permaneceu com o valor 3
. Por outro lado, se utilizarmos o signal
podemos ter este comportamento reativo:
Ambiente para testes
Para você poder testar o Angular Signals há duas formas:
- Atualizar o CLI do Angular no seu ambiente para a versão 16
- Utilizar o https://stackblitz.com
Optei por utilizar o https://stackblitz.com e vou deixar o link abaixo para que você possa efetuar o fork ou mesmo editar o código para fazer alguns testes:
https://stackblitz.com/edit/introducao-angular-signals?file=src%2Fmain.ts
Funções básicas
Indo direto ao básico:
- declaração:
const counter = signal(1);
- leitura:
counter()
- a leitura dos valores é sempre feita via getter - escrita:
counter.set(2);
oucouter.update(value => value + 1)
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-example1',
standalone: true,
imports: [CommonModule],
template: `
<h2>Example 1</h2>
<p>Using <code>set</code> to update value</p>
<div>counter: {{ counter() }}</div>
<div>
<button (click)="increment()">Increment</button>
</div>
`,
})
export class Example1Component {
counter = signal(0);
increment() {
this.counter.set(this.counter() + 1);
}
}
set, update e mutate
Estes 3 métodos servem para atualizar o valor de um signal
. Falando dos dois primeiros:
- O
set
atualiza osignal
com um novo valor:this.counter(1)
- O
update
atualiza o valor de modo que você tenha em mãos o valor anterior:this.counter.update(valorAnterior = > valorAnterior + 1)
Antes de falar do mutate
, tenha em mente que quando atualizamos algum valor o Angular compara o novo valor com o último valor que ele recebeu. Se houver mudanças o Angular segue com o fluxo para notificar o novo valor, caso contrário não faz nada. Podemos ver isto na implementação do método set
(link):
set(newValue: T): void {
if (!this.equal(this.value, newValue)) {
this.value = newValue;
this.valueVersion++;
producerNotifyConsumers(this);
}
}
O this.equal
acima tem a seguinte implementação:
export function defaultEquals<T>(a: T, b: T) {
return (a === null || typeof a !== 'object') && Object.is(a, b);
}
Detalhe importante: repare que se estivermos trabalhando com um object
o retorno da função acima sempre será false
por conta da condição typeof a !== 'object'
. Para estes cenários onde precisamos modificar um object
, por exemplo, adicionar um novo item em um array
, o indicado é o uso do mutate
que faz um bypass neste algoritmo. Veja no exemplo abaixo:
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-example3',
standalone: true,
imports: [CommonModule],
template: `
<h1>Exemplo 3</h1>
<pre>{{ obj() | json }}</pre>
<pre>{{ array() | json }}</pre>
<div>
<button (click)="set()">Set</button>
<button (click)="mutate()">Mutate</button>
</div>
`,
})
export class Example3Component {
obj = signal({ name: 'test' });
array = signal([1, 2, 3]);
set() {
const objModified = this.obj();
objModified.name = 'modified';
this.obj.set(objModified);
this.array.update((currentArray) => {
currentArray.push(4);
return currentArray;
});
}
mutate() {
this.obj.mutate((currentObj) => {
currentObj.name = 'modified';
});
this.array.mutate((currentArray) => {
currentArray.push(4);
});
}
}
When working with signals that contain objects, it's sometimes useful to mutate that object directly. For example, if the object is an array, you may want to push a new value without replacing the array entirely. To make an internal change like this, use the .mutate
method
computed
O método computed
lembra um getter
, que retorna um valor a partir de outros valores. No caso do computed
seu valor é derivado de outros signals. Veja abaixo um exemplo de uso onde o names
retorna um array de nomes derivado de data
:
import { Component, computed, OnInit, signal, WritableSignal } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-example2',
standalone: true,
imports: [CommonModule],
template: `
<h2>Example 2</h2>
<p>Computed</p>
<pre>{{ data() | json }}</pre>
Names: <pre>{{ names() | json }}</pre>
<div>
<button (click)="changeFirstName()">Change first name</button>
</div>
`,
})
export class Example2Component implements OnInit {
data = signal([{ name: 'test' }]);
names = computed(() => {
return this.data().map(a => a.name);
});
ngOnInit() {
const randomArray = new Array(3).fill(null).map((_, index) => ({
name: `Item ${index}`,
}));
this.data.set(randomArray);
}
changeFirstName() {
this.data.mutate((currentArray) => {
if (currentArray.length > 0) {
currentArray[0].name = 'changed';
}
});
}
}
O interessante do computed
é que quando um novo valor é computador pela função que passamos como parâmetro, este valor é salvo em cache. Este cache permanece válido até que o valor de algum signal
dentro desta função notifique que este cache não é mais válido. Veja este exemplo e explicação da documentação oficial:
const count: WritableSignal<number> = signal(0);
const doubleCount: Signal<number> = computed(() => count() * 2);
doubleCount
's derivation function does not run to calculate its value until the first timedoubleCount
is read. Once calculated, this value is cached, and future reads ofdoubleCount
will return the cached value without recalculating.
Whencount
changes, it tellsdoubleCount
that its cached value is no longer valid, and the value is only recalculated on the next read ofdoubleCount
.
As a result, it's safe to perform computationally expensive derivations in computed signals, such as filtering arrays.
effect
O effect
funciona como um "side effect", parecido com o tap
do RxJS. Ele recebe uma função como parâmetro que é executado a princípio uma única vez. Se dentro desta função houver um ou mais valores do tipo sinal
, sempre que houver alguma atualização nestes valores (do tipo signal
) a função será executada. Veja no exemplo a seguir:
import { Component, effect, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-example1',
standalone: true,
imports: [CommonModule],
template: `
<h2>Example 1</h2>
<p>Using <code>set</code> to update value</p>
<div>counter: {{ counter() }}</div>
<div>
<button (click)="increment()">Increment</button>
</div>
`,
})
export class Example1Component {
counter = signal(0);
constructor() {
effect(() => {
console.log(`Counter: ${this.counter()}`);
});
}
increment() {
this.counter.set(this.counter() + 1);
}
}
Effects are rarely needed in most application code, but may be useful in specific circumstances. Here are some examples of situations where aneffect
might be a good solution:
* Logging data being displayed and when it changes, either for analytics or as a debugging tool
* Keeping data in sync withwindow.localStorage
* Adding custom DOM behavior that can't be expressed with template syntax
* Performing custom rendering to a<canvas>
, charting library, or other third party UI library
https://angular.io/guide/signals#effects
Considerações
O Angular Signals ainda está em developer mode, então cuidado ao considerar seu uso em ambiente de produção, mas não deixe de fazer alguns testes para se familiarizar um pouco mais.
Recomendo a leitura da documentação oficial: https://angular.io/guide/signals
Link do StackBlitz com os exemplos utilizados neste texto: https://stackblitz.com/edit/introducao-angular-signals?file=src%2Fmain.ts
Abaixo há alguns links que utilizei para me aprofundar no assunto:
- https://www.freecodecamp.org/news/angular-signals/
- https://dev.to/this-is-angular/angular-signals-everything-you-need-to-know-2b7g
- https://github.com/angular/angular/pull/49091/files#diff-24f7f09e3ccb76e34b1b48d8185a9898af6dac74e8a71f0f2143118ef75fd879
- https://github.com/angular/angular/discussions/49090
- https://itnext.io/angular-signals-the-future-of-angular-395a69e60062
- https://www.youtube.com/watch?v=lXDDNM5rZQI
- https://itnext.io/angular-signals-the-future-of-angular-395a69e60062