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

Banner "Angular Signals" contento a representação de um valor dentro de um cubo
Introdução ao Angular Signals

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.

Como funciona o Change Detection do Angular
Veja como funciona o change detection (CD) do Angular e qual a melhor estratégia para o seu componente: Default ou OnPush.

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.

Diagrama hierárquico dos componentes mostrando a busca por mudanças em cada componente
Árvore de componentes sendo verificados pelo mecanismo de detecção de mudanças

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:

Diagrama hierárquico dos componentes mostrando a busca por mudanças em cada componente não marcado com a estratégia OnPush
Árvore de componentes sendo verificados pelo mecanismo de detecção de mudanças onPush

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 as ExpressionChangedAfterItHasBeenChecked 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:

let a = 1;
let b = 2;
let c = a + b;

console.log(c); // 3

// mesmo alterando o valor de a
a = a + 2;

// ...o valor de "c" continua sendo 3
console.log(c); // 3
Exemplo sem o uso do signal

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:

const a = signal(1);
const b = signal(2);

// O valor de "c" será atualizado
// caso haja alterações nos valores de
// "a" ou "b". Similar a um getter
const c = computed(() => a() + b());
    
console.log(c()); // 3
a.set(a() + 2);
    
console.log(c()); // 5
Exemplo utilizando signal

Ambiente para testes

Para você poder testar o Angular Signals há duas formas:

  1. Atualizar o CLI do Angular no seu ambiente para a versão 16
  2. 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); ou couter.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);
  }
}
Formulário com um botão "Increment" que ao ser clicado atualiza o valor counter na tela
Exemplo do uso do signal

set, update e mutate

Estes 3 métodos servem para atualizar o valor de um signal. Falando dos dois primeiros:

  • O set atualiza o signal 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);
    });
  }
}
Formulário com um botão "Mutate" que ao ser clicado modifica o valor de um objeto e acrescenta uma valor em um array. Ambos os valores são exibidos na tela, antes e depois da atualização.
Exemplo de uso da função mutate
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';
      }
    });
  }

}
Tela mostrando os dados de um Array e um botão "Change first name" que ao ser clicado modifica a propriedade "name" do primeiro objeto de um array.
Exemplo de uso do computed

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 time doubleCount is read. Once calculated, this value is cached, and future reads of doubleCount will return the cached value without recalculating.

When count changes, it tells doubleCount that its cached value is no longer valid, and the value is only recalculated on the next read of doubleCount.

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);
  }
}
Tela exibindo o valor da variável "counter" e um botão "Increment" que ao ser clicado incrementa +1 no valor do "counter" e mostra no console do DevTools que o valor do "counter" foi modificado.
Exemplo do effect
Effects are rarely needed in most application code, but may be useful in specific circumstances. Here are some examples of situations where an effect 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 with window.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: