Destino Dev

Blog

Migrando BehaviorSubject para Signal em formulários Angular — passo a passo

Por John Pires · 11 de março de 2026

Contexto: quando voce vai usar isso

Quando usar

Use este guia quando você tem formulários Angular que hoje são alimentados por BehaviorSubject (ou outros Observables) e quer migrar a lógica para Signal — a API reativa baseada em sinais do Angular. Cenários típicos: formulários de edição que recebem estado inicial do servidor, formulários com pré-carregamento via rota, ou componentes standalone que consumem um serviço de estado.

Por que migrar

Signals trazem simplicidade: leitura síncrona do valor atual, menos boilerplate de subscriptions/unsubscriptions e integração com a reatividade do Angular para atualização de templates. Para iniciantes isso reduz erros comuns como fugas de memória e lógica assíncrona difícil de debugar. Mas a migração exige atenção em como sincronizar sinais com FormControls e nos efeitos colaterais.

Vou mostrar o caminho em modo code-review: primeiro o jeito errado (o que muitos fazem quando tentam migrar rápido), depois a correção com explicações. Foco em formulários reativos (ReactiveFormsModule), components standalone, injeção de dependência via inject() e integração com rotas.

Dica: pense em BehaviorSubject como uma caixa que emite atualizações e em Signal como uma variável reativa. A diferença prática: signal lê direto e dispara re-render automático para quem a consome.

Explicacao visual com diagramas mentais

Analogia simples

Analogia: BehaviorSubject é como um alto-falante que anuncia mudanças; você precisa ligar um fone (subscribe) para ouvir e lembrar de desligar. Signal é como uma variável compartilhada na memória que qualquer um pode ler — quando muda, quem está observando automaticamente é notificado, sem precisar 'ligar o fone'.

Diagrama mental

Diagrama mental mínimo para o fluxo de formulário:

  • Serviço de estado: expõe dados (BehaviorSubject ou Signal).
  • Componente: cria FormGroup e sincroniza com dados do serviço.
  • Template: vincula FormControls para exibir/editar.
  • Routing: fornece id para carregar registro e popular o serviço.

Ao migrar, substituímos o nó 'BehaviorSubject -> subscribe -> setValue' por 'signal -> effect -> setValue' ou por chamadas diretas em respostas a rota. O erro comum é tentar usar a signal como se fosse Observable sem entender a leitura síncrona.

Primeiro exemplo simples

Versão com BehaviorSubject (errado: boilerplate e leaks)

Contexto: serviço expõe BehaviorSubject para os dados iniciais do formulário; o componente se inscreve em ngOnInit e cancela em ngOnDestroy. Isso funciona, mas é verboso e sujeito a vazamentos se esquecer o unsubscribe.

O código abaixo mostra uma versão típica; depois explico o problema e migro para signal.

// profile-state.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { HttpClient } from '@angular/common/http';

export interface Profile { id: string; name: string; email: string }

@Injectable({ providedIn: 'root' })
export class ProfileStateService {
  private profileSubject = new BehaviorSubject<Profile | null>(null);
  readonly profile$ = this.profileSubject.asObservable();

  constructor(private http: HttpClient) {}

  loadProfile(id: string) {
    this.http.get<Profile>(`/api/profiles/${id}`).subscribe(p => this.profileSubject.next(p));
  }

  updateLocal(profile: Profile) {
    this.profileSubject.next(profile);
  }
}

O componente se inscreve:

// profile-form.component.ts
import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { Subscription } from 'rxjs';
import { ProfileStateService } from './profile-state.service';

@Component({ selector: 'app-profile-form', template: `...` })
export class ProfileFormComponent implements OnInit, OnDestroy {
  form: FormGroup;
  sub = new Subscription();

  constructor(private fb: FormBuilder, private state: ProfileStateService) {
    this.form = this.fb.group({ id: [''], name: [''], email: [''] });
  }

  ngOnInit() {
    this.sub.add(this.state.profile$.subscribe(p => {
      if (p) this.form.patchValue(p);
    }));
  }

  ngOnDestroy() { this.sub.unsubscribe(); }
}

O problema: muita boilerplate, necessidade de gerenciar subscriptions manualmente e lógica duplicada quando mais de um componente precisa do mesmo estado.

Migração rapida para Signal (errado: uso indevido)

Um erro comum ao migrar: usar toSignal direto no construtor e pensar que ele sempre refletirá atualizações, ou usar signal mas mutar objetos internos sem criar novo valor, o que evita notificações.

// profile-state-wrong.service.ts
import { Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { BehaviorSubject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class ProfileStateWrongService {
  private profileSubject = new BehaviorSubject<any>(null);
  readonly profileSignal = toSignal(this.profileSubject.asObservable());

  // problema: toSignal já cria um signal, mas se você mutar o objeto retornado, o signal pode não disparar corretamente
  updateName(name: string) {
    const cur = this.profileSignal();
    if (cur) { cur.name = name; /* mutação direta */ }
    // não chama next() no subject; sinal pode não perceber.
  }
}

Depois, o componente poderia ler profileSignal() diretamente — mas a mutação direta do objeto quebra a reatividade. Esse é o tipo de erro que aprisiona iniciantes: mistura de paradigmas.

Segundo exemplo mais realista

Cenário real: formulário de perfil com sinais corretos

Abordagem correta: manter a fonte de verdade no serviço, usar signal como estado primário (ou criar signal a partir do observable, controlando atualização), expor métodos para alterar o estado e usar effect para sincronizar com o FormGroup. Evite mutações diretas.

Abaixo está um serviço que usa Signal nativo do Angular. Ele é standalone-friendly e testável.

// profile-state-signal.service.ts
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';

export interface Profile { id: string; name: string; email: string }

@Injectable({ providedIn: 'root' })
export class ProfileStateSignalService {
  private profile = signal<Profile | null>(null);
  readonly profile$ = this.profile.asReadonly(); // leitura segura

  constructor(private http: HttpClient) {}

  loadProfile(id: string) {
    // chamada HTTP que atualiza a signal
    this.http.get<Profile>(`/api/profiles/${id}`).subscribe(p => this.profile.set(p));
  }

  updateLocal(profile: Profile) {
    // Sempre setar um novo objeto (evitar mutação do objeto atual)
    this.profile.set({ ...profile });
  }

  get profileName() {
    return computed(() => this.profile()?.name ?? '');
  }
}

Contexto: o serviço mantém a signal e dá métodos para carregar e atualizar. Observadores no componente não precisam unsubscribe, pois signals não são Observables; o template reagirá automaticamente quando você consumir a signal através de bindings ou quando efeitos forem estabelecidos no componente.

Componente standalone integrando FormGroup

A integração correta entre FormGroup e signals deve manter o formulário como a fonte de verdade local das alterações (FormControl), e sincronizar o FormGroup quando a signal de estado mudar. Use effect para aplicar alterações da signal ao form, e subscribe (ou valueChanges) para propagar mudanças do form de volta ao serviço.

// profile-form-standalone.component.ts
import { Component, effect, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { ProfileStateSignalService } from './profile-state-signal.service';

@Component({
  selector: 'app-profile-form-standalone',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form">
      <input formControlName="name" />
      <input formControlName="email" />
    </form>
  `
})
export class ProfileFormStandaloneComponent {
  private state = inject(ProfileStateSignalService);
  form = inject(FormBuilder).group({ id: [''], name: [''], email: [''] });

  constructor() {
    // aplicar dados da signal para o form quando mudarem
    effect(() => {
      const p = this.state.profile$();
      if (p) this.form.patchValue(p, { emitEvent: false });
    });

    // propagar mudanças do form para o serviço
    this.form.valueChanges.subscribe(v => {
      if (v && v.id) this.state.updateLocal({ ...v });
    });
  }
}

O que acontece: quando o serviço chama loadProfile(), a signal muda e o effect no componente executa, chamando patchValue. valueChanges é usado para enviar alterações de volta ao serviço. Não há unsubscribe do effect; o Angular gerencia sua vida útil junto com o componente.

Integração com routing e dependency injection

Carregando via rota (exemplo prático)

Frequentemente o parâmetro de rota contém o id do recurso a ser editado. Em components standalone você pode usar ActivatedRoute via inject().

// profile-route.component.ts
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ProfileStateSignalService } from './profile-state-signal.service';

@Component({ selector: 'app-profile-route', template: `<app-profile-form-standalone></app-profile-form-standalone>`, standalone: true })
export class ProfileRouteComponent {
  private route = inject(ActivatedRoute);
  private state = inject(ProfileStateSignalService);

  constructor() {
    const id = this.route.snapshot.paramMap.get('id');
    if (id) this.state.loadProfile(id);
  }
}

Contexto: usando snapshot é simples e síncrono para o carregamento inicial. Se precisar reagir a mudanças de rota, use route.paramMap.subscribe ou use effect combinado com a rota (evite mixar sinais de rota diretamente sem ler a documentação).

Dependência e escopo do serviço

Decida o provider scope: em muitos casos o serviço de estado deve ser providedIn: 'root' para ser singleton entre rotas; em outros, você quer escopo por rota e então forneceria o serviço no componente de rota. Isso influencia se o estado persiste quando o usuário navega para fora e volta.

Resumo prático: signals facilitam a leitura síncrona e diminuem gerenciamento manual de subscriptions; mas escolha bem o escopo do serviço.

Erros comuns de iniciante e como evitar

Leaky subscriptions (subscribe sem unsubscribe)

Erro: manter vários subscribe() em componentes e esquecer de cancelar — causa memory leaks e múltiplas atualizações inesperadas. Com signals, você reduz esse risco ao usar effect() em vez de subscribe(). Quando usar Observables (ex: HttpClient), prefira operadores como take(1) ou transformar o observable em signal com cuidado.

Mutação de sinais

Erro: alterar propriedades internas do objeto armazenado em uma signal em vez de criar um novo objeto. Exemplo: fazer const p = profileSignal(); p.name = 'x' não dispara notificação se não houver clonagem e depende de referências. Solução: sempre usar set() com novo objeto: profile.set({ ...profile(), name: 'x' }).

Outros problemas relacionados:

  • Converter Observable para Signal com toSignal() sem entender o valor inicial e a forma como a cancelamento funciona.
  • Usar effect para fazer chamadas HTTP sem debouncing pode disparar muitas requisições.
  • Tentar usar signal diretamente em template com forms template-driven — prefira ReactiveForms quando for sincronizar sinais.
Dica: para sincronizar formulário e signal, trate o FormGroup como fonte de verdade enquanto o usuário edita e atualize a signal somente quando fizer save ou em valueChanges com debounce.

Conclusao pratica

Checklist de migração

Checklist rápido para migrar um formulário de BehaviorSubject para Signal sem surpresas:

  1. Decida o escopo do serviço (root vs componente/route).
  2. Substitua BehaviorSubject por signal() no serviço e exponha leitura com asReadonly() se necessário.
  3. Evite mutação: sempre chame set() com novo objeto.
  4. No componente, use effect() para sincronizar signal -> form e form.valueChanges para propagar form -> signal (use debounce quando necessário).
  5. Teste rotas e lifecycle para garantir que efeitos não disparam em loops.

Proximos passos

Depois da migração básica, explore: computed para valores derivados, toObservable quando precisa interoperar com APIs RxJS, e padrões de state management com sinais (pequenas stores). Sempre escreva testes unitários para assegurar que os efeitos sincronizam corretamente e que não há regressões no comportamento do formulário.

Este guia mostrou o caminho do jeito errado ao certo, com exemplos práticos e funcionais. A técnica principal é: mantenha no serviço a fonte de verdade com signals, evite mutações e use effects para sincronizar com formulários. Assim você reduz boilerplate e ganha previsibilidade.

Implementar sinais em formulários é um exercício de disciplina: sinais para estado, FormControls para edição local, efeitos para sincronização. Se seguir esse padrão, seu código ficará mais simples, menos propenso a leaks e mais fácil de testar.

Para referência, verifique a documentação do Angular sobre Signals, ReactiveFormsModule, e a interop entre RxJS e sinais (toSignal, toObservable).

Assuntos: Angular, signals, rxjs, forms, standalone-components, dependency-injection, routing

Leia também