Angular Signals: reatividade granular


O preço escondido do change detection

Tem um custo que quase ninguém mede no Angular. Falo do tempo que o próprio Angular gasta perguntando “será que mudou alguma coisa?” justamente para os pedaços da tela onde nada mudou. Por anos, esse foi o preço de entrada, porque ele nasceu casado com o Zone.js, uma biblioteca que aplica monkey-patch em quase tudo que é assíncrono no navegador, de cliques a timers, de requisições HTTP a promessas, só para sussurrar uma frase ao runtime: aconteceu algo, hora de conferir.

Depois desse aviso é que mora o problema. Disparou o Zone, o Angular percorre a árvore de componentes inteira, de cima a baixo, comparando o valor atual de cada binding com o valor anterior. Isso é o dirty checking. Numa telinha, ninguém sente. Agora pensa num dashboard com centenas de componentes, listas virtualizadas e dado chegando o tempo todo, onde cada clique perdido vira uma varredura completa da árvore, mesmo que só um contador esquecido no canto precisasse mudar.

Já perdi tardes caçando travadas em telas de mapa abarrotadas de elementos. Quase sempre a raiz era a mesma: change detection rodando muito além do necessário. É esse desperdício que os Signals atacam de frente.

@Component({
  selector: "app-contador",
  template: `<button (click)="incrementar()">{{ contador }}</button>`,
})
export class ContadorComponent {
  contador = 0;

  incrementar() {
    this.contador++;
  }

  // Qualquer evento que o Zone.js intercepta (este clique, um setTimeout,
  // a resposta de um HTTP em outro canto do app) agenda um ciclo de change
  // detection que percorre a árvore inteira, reavaliando bindings que nem
  // sequer mudaram.
}

O que é um Signal

Um Signal é um contêiner reativo em volta de um valor. Soa simples. E é. Mas a graça mora no “reativo”: ele sabe quem está lendo o valor que guarda e avisa esses leitores no instante em que esse valor troca. Não é EventEmitter. Não é BehaviorSubject. Lá no fundo, é um valor que carrega junto o grafo de quem depende dele.

Ler é chamar uma função. Quando você escreve count() para pegar o conteúdo atual, essa chamada não é enfeite: é nela que o Signal registra que alguém ali o consultou. Escrever tem dois caminhos: set() entra quando você já tem o valor novo na mão, e update() entra quando o próximo estado nasce do anterior.

import { signal } from "@angular/core";

const contador = signal(0);

contador(); // leitura -> 0
contador.set(10); // escrita direta
contador.update((n) => n + 1); // escrita derivada do valor anterior

console.log(contador()); // 11

Valores derivados com computed

Quase nunca o estado que interessa é o estado cru. Você tem uma lista e quer a contagem de não lidos. Ou um carrinho, e você quer o total. Entra o computed(): você descreve como chegar ao valor a partir de outros signals, e o Angular toma conta sozinho da orquestração, da invalidação e do cache.

São três qualidades que fazem o computed valer ouro. Memoização: ele guarda o último resultado e devolve esse cache enquanto nada mudar. Preguiça: o cálculo só roda quando alguém realmente lê. Fecha a lista a consistência: ele nunca expõe um estado intermediário quebrado no meio de uma atualização.

import { signal, computed } from "@angular/core";

const itens = signal([
  { nome: "Teclado", preco: 250 },
  { nome: "Mouse", preco: 120 },
]);

const total = computed(() =>
  itens().reduce((soma, item) => soma + item.preco, 0),
);

total(); // 370 -> calculado e memoizado

itens.update((lista) => [...lista, { nome: "Monitor", preco: 900 }]);

total(); // 1270 -> recalculado só agora, porque foi lido de novo

Efeitos colaterais com effect

Nem tudo é cálculo. Vez ou outra você precisa reagir a uma mudança mexendo no mundo lá fora, seja gravar no localStorage, logar um evento ou disparar uma requisição, e esse é justamente o trabalho do effect(): ele executa uma vez, observa quais signals foram lidos no caminho e, quando algum deles muda, executa tudo de novo.

Existe uma beleza aqui que o useEffect do React não tem: nenhuma lista de dependências para declarar. Sozinho, o effect descobre o que você tocou. Leu um signal dentro de um if? Então essa dependência só passa a existir quando aquele ramo roda. Nada de array para manter na unha.

import { signal, effect } from "@angular/core";

const tema = signal<"claro" | "escuro">("claro");

effect(() => {
  // 'tema' é lido aqui, então vira dependência deste efeito.
  localStorage.setItem("tema", tema());
  console.log("tema salvo:", tema());
});

tema.set("escuro"); // o effect roda de novo, sozinho

Por baixo do capô

Chegamos na parte que mudou a minha cabeça sobre reatividade. Internamente, um Signal não tem mágica nenhuma: é um grafo de dependências dirigido, com os produtores de um lado, que são os signals guardando valor, e os consumidores do outro, que são o computed e o effect, ligados por uma aresta que surge toda vez que um consumidor lê um produtor.

O que deixa esse grafo barato é o modelo híbrido de push e pull. Escreveu num Signal? Ele não recalcula nada ali: dá só um push leve, marcando os dependentes como sujos, um aviso de que o valor que eles guardam talvez esteja velho. Sujar é baratíssimo, vira uma flag e pronto. O serviço pesado, o recálculo de verdade, fica reservado para o pull, o instante exato em que alguém lê um computed sujo. Ninguém lendo, ninguém paga a conta.

Dinâmico, o rastreio de dependências é a sacada que poucos param para apreciar. Enquanto um computed ou um effect executa, existe um consumidor ativo registrado num ponto global, e todo Signal lido durante essa janela se inscreve como dependência daquela execução específica. Terminou, o grafo daquele consumidor foi remontado do zero. Daí dependência condicional funcionar sem gambiarra: se o cálculo leu o signal A e, conforme uma flag, às vezes o B, o grafo reflete com exatidão o que foi lido naquela passada.

Como saber se algo de fato mudou? Versão. Cada produtor carrega um número que sobe a cada alteração de valor, e o computed anota quais eram as versões das suas dependências no último cálculo. Lido de novo já marcado como sujo, ele não recalcula no susto: confere as versões de agora contra as que tinha anotado. Nenhuma subiu? Devolve o cache e segue a vida. Aqui está o pulo do gato para não refazer derivação cara à toa.

Fechando o ciclo, vem a checagem de igualdade. No padrão, o Signal compara o valor novo com o atual via Object.is. Deu igual? Então a propagação morre ali mesmo, sem marcar ninguém como sujo e sem acordar nenhum efeito. Para objetos, onde igualdade por referência raramente é o que você quer, dá para injetar uma função equal sob medida.

Junte tudo isso e cai no seu colo, de graça, uma propriedade que noutros modelos reativos custa caro: a ausência de glitches. Como o recálculo é preguiçoso e só acontece na leitura, um computed que depende de dois signals alterados no mesmo ciclo recalcula uma única vez, já com os dois valores frescos, e você nunca flagra aquele frame inconsistente em que metade do estado é novo e a outra metade ainda é velha.

O que isso faz com a performance

Toda essa mecânica aponta para um lugar só: o Angular passa a saber, com precisão de bisturi, o que depende de quê. Aí a performance vira outra história.

Antes, mexeu em qualquer canto, o Angular era convidado a checar a árvore toda. Com signals lidos no template, o componente conhece exatamente quais signals consome, de modo que, quando um deles muda, só o que depende daquele signal é reavaliado. Em vez de varrer mil bindings para corrigir um, ele corrige um. Só um.

Esse caminho abre a porta que mais me empolga: as aplicações zoneless, sem Zone.js. Tirando o monkey-patch de todo o assíncrono, some um overhead constante de runtime. De quebra, o bundle encolhe. A reatividade deixa de ser “aconteceu algo, confira tudo” e vira “este valor mudou, atualize só quem usa”.

No dia a dia, signals se dão muito bem com OnPush e tornam o comportamento previsível. Menos verificações. Menos re-renders. Some a CPU torrada à toa em tela densa e lista quilométrica. Some também o custo de derivação pesada, porque a memoização do computed segura o filtro ou a ordenação sobre milhares de itens enquanto a entrada não mexer.

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  signal,
} from "@angular/core";

@Component({
  selector: "app-carrinho",
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>Itens: {{ quantidade() }}</p>
    <p>Total: {{ total() | currency: "BRL" }}</p>
    <button (click)="adicionar()">Adicionar</button>
  `,
})
export class CarrinhoComponent {
  protected readonly itens = signal<number[]>([]);
  protected readonly quantidade = computed(() => this.itens().length);
  protected readonly total = computed(() =>
    this.itens().reduce((soma, preco) => soma + preco, 0),
  );

  adicionar() {
    this.itens.update((lista) => [...lista, 100]);
  }
}

Signals no componente do dia a dia

Os signals chegaram no Angular 16, em developer preview, e amadureceram da 17 à 19, quando entraram de vez na própria API dos componentes. As entradas largaram o decorator @Input() e viraram signals de verdade, criadas com input(), ou com input.required() quando o valor não pode faltar. Ler uma entrada virou ler um signal, com todo o rastreio embutido. Sobrou o model() para a ligação de mão dupla, um signal gravável já amarrado ao binding do componente pai.

import { Component, input, model } from "@angular/core";

@Component({
  selector: "app-campo-busca",
  template: `
    <label>{{ rotulo() }}</label>
    <input
      [value]="termo()"
      [placeholder]="placeholder()"
      (input)="termo.set($any($event.target).value)"
    />
  `,
})
export class CampoBuscaComponent {
  // input obrigatório: o componente pai precisa fornecer
  rotulo = input.required<string>();

  // input opcional, com valor padrão
  placeholder = input("Buscar...");

  // two-way binding: o pai usa [(termo)]="..."
  termo = model("");
}

Conviver com o RxJS

Vou dizer com todas as letras, porque a confusão é comum: signal não veio aposentar o RxJS. São ferramentas para dores diferentes. Signal é estado reativo síncrono, o valor que existe agora, neste instante. Já o RxJS é fluxo, é tempo, é o assíncrono cabeludo com seus operadores de orquestração. Felizmente, os dois conversam por duas pontes oficiais: toSignal() consome um Observable como se fosse signal e ainda cancela a inscrição por você, enquanto toObservable() faz o contrário, expondo um signal como stream.

import { Component, inject } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { HttpClient } from "@angular/common/http";

interface Usuario {
  id: number;
  nome: string;
}

@Component({
  selector: "app-usuarios",
  template: `
    @for (usuario of usuarios(); track usuario.id) {
      <li>{{ usuario.nome }}</li>
    }
  `,
})
export class UsuariosComponent {
  private readonly http = inject(HttpClient);

  // o Observable vira signal; a inscrição é encerrada sozinha
  usuarios = toSignal(this.http.get<Usuario[]>("/api/usuarios"), {
    initialValue: [],
  });
}

Por que vale a pena

Depois de levar signal para produção, o que me convence não é um benchmark isolado. É a soma das partes. Olhando o código, a reatividade fica às claras, e dá para seguir o caminho exato de quem depende de quem. Ganha de brinde a memoização do computed, sem esforço algum. Para estado síncrono, evapora um bom tanto do boilerplate que o RxJS cobrava. E o melhor: o change detection deixa de ser marreta e vira bisturi, o que aparece direto na fluidez da tela.

Nada disso é bala de prata. Quando o assunto é orquestrar requisição, debounce, retry e cancelamento, o RxJS segue imbatível, e tentar imitar isso só com signal é pedir dor de cabeça. Encare como divisão de trabalho: o signal cuida do estado, o observable cuida dos fluxos, e as pontes existem para os dois se encontrarem na hora certa. Foi assim que parei de brigar com o change detection e passei a deixá-lo trabalhar do meu lado.