Capítulo 06: Reatividade com Angular Signals (signal, computed, effect)

Especialização em Front-End com Angular • Angular 18+ • Signals, Standalone Architecture, RxJS, NgRx e SSR


🗺️ Mapa Conceitual do Grafo Reativo dos Signals

flowchart TD
    subgraph WRITABLE["Produtores Primários (Writable Signals)"]
        S1["preco = signal(100)"]
        S2["quantidade = signal(2)"]
        S3["desconto = signal(0.10)"]
    end

    subgraph DERIVED["Consumidores & Produtores Derivados (Computed Signals)"]
        C1["subtotal = computed(() => preco() * quantidade())"]
        C2["total = computed(() => subtotal() * (1 - desconto()))"]
    end

    subgraph EFFECTS["Consumidores Finais (Efeitos Colaterais & UI)"]
        E1["effect(() => console.log('Total:', total()))"]
        E2["Template View: {{ total() | currency }}"]
    end

    S1 & S2 --> C1
    C1 & S3 --> C2
    C2 --> E1 & E2

    style WRITABLE fill:#e1f5fe,stroke:#03a9f4,stroke-width:2px
    style DERIVED fill:#fff3e0,stroke:#ff9800,stroke-width:2px
    style EFFECTS fill:#ede7f6,stroke:#7e57c2,stroke-width:2px

🏛️ 1. O Modelo Mental dos Signals no Angular

Os Signals são a espinha dorsal da nova arquitetura reativa do Angular. Um Signal é um invólucro reativo em torno de um valor que notifica consumidores interessados sempre que esse valor se altera.

Ao contrário de sistemas reativos baseados em sujas varreduras completas do DOM (dirty checking via Zone.js), os Signals utilizam um modelo Push-Pull Glitch-Free:

  • Push: Notifica o grafo de que uma dependência mudou de estado.
  • Pull: Computa o novo valor sob demanda (lazy evaluation) apenas no momento exato em que a leitura for solicitada por um template ou efeito.
  • Glitch-Free: Garante que estados intermediários inconsistentes nunca sejam observados pela aplicação.

⚙️ 2. Primitivas Fundamentais de Signals

2.1 Writable Signal: Leitura e Escrita

Criado via função signal(valorInicial). Retorna um WritableSignal<T> com métodos para atualização:

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-contador',
  standalone: true,
  template: `
    <div class="painel">
      <p>Valor Atual: <strong>{{ contador() }}</strong></p>
      <button (click)="incrementar()">+1</button>
      <button (click)="decrementar()">-1</button>
      <button (click)="resetar()">Zerar</button>
    </div>
  `
})
export class ContadorComponent {
  contador = signal<number>(0);

  incrementar(): void {
    // .update() recebe o valor anterior e retorna o novo
    this.contador.update(valorAnterior => valorAnterior + 1);
  }

  decrementar(): void {
    this.contador.update(v => Math.max(0, v - 1));
  }

  resetar(): void {
    // .set() substitui o valor incondicionalmente
    this.contador.set(0);
  }
}

2.2 Computed Signal: Valores Derivados e Memoizados

Criado via função computed(() => expressao). Um computed é somente-leitura e rastreia dinamicamente quais signals são lidos dentro da sua função executora. Se nenhum dos signals dependentes mudar, leituras subsequentes retornam o valor em cache instantaneamente em $O(1)$.

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

@Component({
  selector: 'app-carrinho-compras',
  standalone: true,
  template: `
    <div>
      <p>Preço: {{ preco() }} | Quantidade: {{ quantidade() }}</p>
      <p>Subtotal: {{ subtotal() }}</p>
      <p>Total com Frete: {{ totalComFrete() }}</p>
    </div>
  `
})
export class CarrinhoComponent {
  preco = signal(150);
  quantidade = signal(3);
  freteGratis = signal(false);

  // Rastreia dinamicamente preco() e quantidade()
  subtotal = computed(() => this.preco() * this.quantidade());

  // Rastreamento condicional dinâmico
  totalComFrete = computed(() => {
    const base = this.subtotal();
    if (this.freteGratis() || base > 300) {
      return base; // Não lê o valor do frete se for grátis
    }
    return base + 25;
  });
}

2.3 Effect: Efeitos Colaterais Controlados

Criado via effect(() => ...). É executado de forma assíncrona após as alterações de estado, ideal para sincronizar com APIs de terceiros, localStorage, WebSockets ou manipulações imperativas do DOM:

import { Component, signal, effect, inject } from '@angular/core';

@Component({
  selector: 'app-tema-usuario',
  standalone: true,
  template: `<button (click)="alternarTema()">Mudar para {{ proximoTema() }}</button>`
})
export class TemaUsuarioComponent {
  tema = signal<'claro' | 'escuro'>('claro');
  proximoTema = computed(() => this.tema() === 'claro' ? 'escuro' : 'claro');

  constructor() {
    // O effect precisa ser registrado em um contexto de injeção (construtor)
    effect((onCleanup) => {
      const temaAtual = this.tema();
      document.body.setAttribute('data-theme', temaAtual);
      localStorage.setItem('user-theme', temaAtual);

      // Função de limpeza opcional (executa antes da próxima execução ou na destruição)
      onCleanup(() => {
        console.log(`Limpando configurações do tema anterior: ${temaAtual}`);
      });
    });
  }

  alternarTema(): void {
    this.tema.update(t => t === 'claro' ? 'escuro' : 'claro');
  }
}

🔍 3. Técnicas Avançadas com Signals

3.1 Isolando Dependências com untracked()

Quando você precisa ler um signal dentro de um computed ou effect sem registrar aquele signal como dependência reativa:

import { effect, untracked } from '@angular/core';

effect(() => {
  const usuario = this.usuarioLogado(); // Rastreia: reexecuta se o usuário mudar
  
  // Lê as configurações sem reexecutar o effect caso elas mudem
  const configs = untracked(() => this.configuracoesLocais());
  
  analyticsService.logPageView(usuario.id, configs.debugMode);
});

3.2 Custom Equality Functions

Por padrão, os signals utilizam Object.is para saber se o valor mudou. Para objetos ou coleções com comparação estrutural customizada:

const usuario = signal({ id: 1, nome: 'Ana' }, {
  equal: (a, b) => a.id === b.id && a.nome === b.nome
});

⚖️ 4. Comparativo: Signals vs Observables (RxJS) vs Zone.js

Critério Angular Signals RxJS Observables Zone.js Tradicional
Natureza Estado síncrono e valor pontual Streams de eventos assíncronos Monkey-patching de APIs do browser
Sincronismo Síncrono (signal()) Assíncrono ou Síncrono Assíncrono
Necessidade de Cleanup Não precisa de unsubscribe Obrigatório (takeUntilDestroyed) Automático via Monkey-patching
Interoperabilidade toSignal(), toObservable() Fonte primária assíncrona Acoplado ao framework legada
Performance de Render Granularidade Fina (Zoneless) Depende do AsyncPipe Varredura de árvore inteira

⚠️ 5. Armadilhas Críticas ao Usar Signals

  1. Escrever em Signals dentro de um computed:
    • Regra inviolável: A função dentro de computed() DEVE SER PURA e livre de efeitos colaterais. NUNCA faça meuOutroSignal.set() dentro de um computed. O Angular disparará um erro de runtime.
  2. Ciclos Infinitos em effect():
    • Se um effect altera um signal que ele mesmo consome, ele entrará em loop infinito. Por padrão, o Angular desabilita allowSignalWrites: true em effects exatamente para forçar um fluxo de dados unidirecional sadio.
  3. Instanciar effect() Fora de um Injection Context:
    • Chamar effect() em métodos comuns ou fora do constructor gera o erro NG0203: effect() must be called in an injection context. Se necessário criar fora do construtor, passe explicitamente { injector: this.injector }.

🛡️ 6. Boas Práticas e Padrões Arquiteturais

  • Imutabilidade em Coleções: Embora o método .mutate() tenha sido descontinuado, utilize sempre atualizações imutáveis com .update():
    // Adicionar item
    this.itens.update(lista => [...lista, novoItem]);
    // Remover item
    this.itens.update(lista => lista.filter(i => i.id !== idRemover));
    
  • Evitar Abuso de Effects: Use computed() para 90% dos casos de sincronização de dados. Reserve effect() estritamente para logging, persistência local, chamadas de bibliotecas de terceiros imperativas (D3, Chart.js, Leaflet) ou mutações do DOM real.

📋 7. Cheat-Sheet: Resumo Executivo

const count = signal(0);                  // WritableSignal
count.set(10);                             // Substitui
count.update(c => c + 1);                  // Deriva do anterior
const double = computed(() => count() * 2);// Computed (Readonly)

effect(() => {                             // Effect (Side-effect)
  console.log('Valor atual:', count());
});