DEV Community

Bruno Donatelli
Bruno Donatelli

Posted on

Angular Forms Signal: O que as documentações não te falam

Uma Jornada de Transformação: Do Tradicional ao Moderno


Introdução

Com o lançamento do Angular 21, uma das features experimentais mais empolgantes é o Signal Forms (@angular/forms/signal). Esta nova API representa uma evolução significativa na forma como construímos e gerenciamos formulários, oferecendo uma developer experience mais agradável, abordagem mais simples e alinhada com a direção que o framework vem adotando.

Este artigo documenta a migração de uma aplicação de reserva de voos, mostrando como transformamos formulários reativos tradicionais em uma implementação baseada em signals, explorando os benefícios, desafios e mudanças de paradigma.

repo: forms signal


Índice de Navegação

Seções Principais

  1. Introdução
  2. O Projeto: Sistema de Reserva de Voos
  3. As Etapas do Formulário
  4. Reactive Forms: A Abordagem Tradicional
  5. Forms/Signal: A Nova Era
  6. Migração: Da Teoria à Prática
  7. Tratamento de Erros com Signal Forms
  8. Casos de Uso Avançados
  9. Tabela Comparativa: Reactive Forms vs Signal Forms
  10. Conclusão
  11. Referências

O Projeto: Sistema de Reserva de Voos

O projeto desenvolvido é uma aplicação de reserva de voos que implementa um formulário multi-etapas com formulários dinâmicos, validações complexas e integração com APIs externas (ViaCEP para endereços brasileiros).

Arquitetura

O projeto mantém duas implementações paralelas:

  • flight-booking-legacy/: Implementação com Reactive Forms tradicional
  • flight-booking/: Nova implementação com Signal Forms

Esta abordagem permite comparação direta entre as duas tecnologias e facilita o aprendizado incremental.


As Etapas do Formulário

O formulário de reserva é dividido em 4 steps principais, cada uma com sua complexidade específica:

Step 1: Detalhes do Voo (Flight Details)

Etapa inicial onde são definidos os dados base da viagem:

Campos:

  • Tipo de voo (ida e volta / somente ida)
  • Origem e destino (códigos de aeroporto de 3 caracteres)
  • Datas de partida e retorno
  • Número de passageiros (adultos, crianças, bebês)
  • Classe do voo (econômica, premium, executiva, primeira classe)

Validações especiais:

  • Data de retorno obrigatória apenas para voos de ida e volta
  • Data de retorno deve ser posterior à data de partida
  • Códigos de aeroporto devem ter exatamente 3 caracteres

Step 2: Detalhes dos Passageiros (Passenger Details)

Esta etapa é dinâmica e se adapta ao número de passageiros do primeiro step. O formulário cresce e diminui conforme necessário.

Para cada passageiro:

  • Nome e sobrenome
  • Data de nascimento
  • Gênero
  • Número de passaporte
  • Nacionalidade
  • Tipo de passageiro (adulto/criança) - atribuído automaticamente

Características técnicas:

  • Implementado com FormArray (Reactive Forms) ou field (Signal Forms)
  • Formulários são adicionados/removidos dinamicamente conforme o total de passageiros muda

Step 3: Serviços (Services)

Aqui o passageiro escolhe os serviços adicionais para sua viagem:

Serviços disponíveis:

  • Bagagem despachada (checked bags)
  • Bagagem de mão (carry-on)
  • Seleção de assentos (array)
  • Preferências de refeição (array)
  • Seguro viagem (boolean)

Step 4: Pagamento (Payment)

O step mais complexo, com sub-formulários dinâmicos que mudam baseados no método de pagamento selecionado.

Lembrando que: Esse projeto não é um projeto do mundo real, portanto nenhum dado é salvo em nenhum banco de dados ou qualquer coisa.

Métodos de pagamento e seus formulários:

  1. Cartão de Crédito/Débito:

    • Número do cartão
    • Nome no cartão
    • Data de validade
    • CVV
    • Endereço de cobrança completo
  2. PIX:

    • CPF
    • Email
    • Nome completo
  3. Boleto Bancário:

    • Tipo de documento (CPF/CNPJ)
    • Número do documento
    • Nome completo
    • Endereço completo brasileiro com integração ViaCEP

Integração ViaCEP:
Quando o usuário informa o CEP no formulário de Boleto, a aplicação automaticamente preenche:

  • Logradouro (rua)
  • Bairro
  • Cidade
  • Estado

Reactive Forms: A Abordagem Tradicional

Como Reactive Forms Lidam com Formulários

No Angular tradicional, os Reactive Forms são construídos usando o FormBuilder e uma hierarquia de FormGroup, FormControl e FormArray. Vamos analisar a implementação que chamaremos de "legacy" do nosso projeto.

Estrutura do Formulário Principal

// flight-booking-legacy/flight-search.ts
export class FlightSearchComponent implements OnInit, OnDestroy {
  private readonly fb = inject(NonNullableFormBuilder);
  private readonly destroy$ = new Subject<void>();

  // Formulário principal com hierarquia complexa
  bookingForm = this.fb.group<BookingForm>({
    flightDetails: this.createFlightDetailsGroup(),
    passengerDetails: this.fb.array<FormGroup<PassengerDetailForm>>([]),
    services: this.createServicesGroup(),
    payment: this.createPaymentGroup()
  });

  ngOnInit(): void {
    // Subscrição para mudanças no número de passageiros
    this.bookingForm.controls.flightDetails.valueChanges
      .pipe(takeUntil(this.destroy$))
      .subscribe((flightDetails) => {
        if (flightDetails.passengers) {
          this.updatePassengerForms(flightDetails.passengers);
        }
      });

    // Subscrição para mudanças no método de pagamento
    this.bookingForm.controls.payment
      .get('paymentMethod')
      ?.valueChanges.pipe(takeUntil(this.destroy$))
      .subscribe((method) => {
        this.updatePaymentForm(method);
      });
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }

  // Criação do grupo de detalhes do voo
  private createFlightDetailsGroup(): FormGroup<FlightDetailsForm> {
    const group = this.fb.group<FlightDetailsForm>(
      {
        flightType: this.fb.control(FLIGHT_TYPE.ROUNDTRIP),
        origin: this.fb.control('', {
          validators: [Validators.required, Validators.minLength(3)],
        }),
        destination: this.fb.control('', {
          validators: [Validators.required, Validators.minLength(3)],
        }),
        departureDate: this.fb.control('', {
          validators: [Validators.required],
        }),
        returnDate: this.fb.control(''),
        passengers: this.fb.group<PassengersForm>({
          adults: this.fb.control(1, {
            validators: [Validators.required, Validators.min(1)],
          }),
          children: this.fb.control(0, {
            validators: [Validators.min(0)],
          }),
        }),
        class: this.fb.control(FLIGHT_CLASS.ECONOMY),
      },
      {
        validators: [this.returnDateValidator],
      }
    );

    return group;
  }
}


Enter fullscreen mode Exit fullscreen mode

Sim, funciona.

Sim, está correto.
Não, ninguém quer manter isso para sempre (ou quer, né... vai saber).

Validadores Customizados

// Validador cross-field para data de retorno
private returnDateValidator(control: AbstractControl): ValidationErrors | null {
  const flightType = control.get('flightType')?.value;
  const returnDate = control.get('returnDate')?.value;
  const departureDate = control.get('departureDate')?.value;

  if (flightType === FLIGHT_TYPE.ROUNDTRIP) {
    if (!returnDate) {
      return { returnDateRequired: true };
    }

    if (
      departureDate &&
      returnDate &&
      new Date(returnDate) <= new Date(departureDate)
    ) {
      return { returnDateInvalid: true };
    }
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Gerenciamento de FormArray Dinâmico

// Atualização dinâmica de passageiros
private updatePassengerForms(passengers: Passengers): void {
  const totalPassengers = passengers.adults + passengers.children;
  const currentPassengers = this.passengerDetailsArray.length;

  if (totalPassengers > currentPassengers) {
    // Adicionar novos formulários
    for (let i = currentPassengers; i < totalPassengers; i++) {
      const passengerType = i < passengers.adults
        ? PASSENGER_TYPE.ADULT
        : PASSENGER_TYPE.CHILD;
      this.passengerDetailsArray.push(
        this.createPassengerDetailGroup(passengerType)
      );
    }
  } else if (totalPassengers < currentPassengers) {
    // Remover formulários excedentes
    for (let i = currentPassengers - 1; i >= totalPassengers; i--) {
      this.passengerDetailsArray.removeAt(i);
    }
  }
}

private createPassengerDetailGroup(
  type: PassengerType
): FormGroup<PassengerDetailForm> {
  return this.fb.group<PassengerDetailForm>({
    firstName: this.fb.control('', {
      validators: [Validators.required, Validators.minLength(2)],
    }),
    lastName: this.fb.control('', {
      validators: [Validators.required, Validators.minLength(2)],
    }),
    dateOfBirth: this.fb.control('', {
      validators: [Validators.required],
    }),
    gender: this.fb.control('', {
      validators: [Validators.required],
    }),
    passport: this.fb.control('', {
      validators: [
        Validators.required,
        Validators.pattern(/^[A-Z0-9]{6,9}$/),
      ],
    }),
    nationality: this.fb.control('', {
      validators: [Validators.required],
    }),
    type: this.fb.control(type),
  });
}
Enter fullscreen mode Exit fullscreen mode

Sub-formulários Dinâmicos de Pagamento

Ao trocar o método de pagamento, precisamos adicionar e remover controles do formulário de forma dinâmica.

// Mudança dinâmica de formulário baseada no método de pagamento
private updatePaymentForm(method: string | null | undefined): void {
  if (!method) return;

  const paymentControl = this.bookingForm.controls.payment;

  switch (method) {
    case PAYMENT_METHOD.CREDIT_CARD:
    case PAYMENT_METHOD.DEBIT_CARD:
      paymentControl.setControl('cardDetails', this.createCardDetailsGroup());
      paymentControl.removeControl('pixDetails');
      paymentControl.removeControl('boletoDetails');
      break;

    case PAYMENT_METHOD.PIX:
      paymentControl.setControl('pixDetails', this.createPixDetailsGroup());
      paymentControl.removeControl('cardDetails');
      paymentControl.removeControl('boletoDetails');
      break;

    case PAYMENT_METHOD.BOLETO:
      paymentControl.setControl('boletoDetails', this.createBoletoDetailsGroup());
      paymentControl.removeControl('cardDetails');
      paymentControl.removeControl('pixDetails');
      break;
  }
}

private createCardDetailsGroup(): FormGroup<CardDetailsForm> {
  return this.fb.group<CardDetailsForm>({
    cardNumber: this.fb.control('', {
      validators: [Validators.required, Validators.pattern(/^\d{16}$/)],
    }),
    cardholderName: this.fb.control('', {
      validators: [Validators.required],
    }),
    expirationDate: this.fb.control('', {
      validators: [Validators.required, Validators.pattern(/^\d{2}\/\d{2}$/)],
    }),
    cvv: this.fb.control('', {
      validators: [Validators.required, Validators.pattern(/^\d{3,4}$/)],
    }),
    billingAddress: this.createAddressGroup(),
  });
}
Enter fullscreen mode Exit fullscreen mode

Tratamento de Erros no Template (Reactive Forms)

Template de Detalhes de Passageiros

<!-- flight-booking-legacy/passenger-details-step/passenger-details-step.component.html -->
<div class="passenger-form" [formGroup]="passengerForm">
  <div class="form-group">
    <label [for]="'firstName-' + index">First Name *</label>
    <input
      [id]="'firstName-' + index"
      type="text"
      formControlName="firstName"
      class="form-control"
      [class.invalid]="isFieldInvalid(passengerForm.controls.firstName)"
    />
    @if (isFieldInvalid(passengerForm.controls.firstName)) {
      <span class="error-message">
        {{ getFieldError(passengerForm.controls.firstName) }}
      </span>
    }
  </div>

  <div class="form-group">
    <label [for]="'passport-' + index">Passport Number *</label>
    <input
      [id]="'passport-' + index"
      type="text"
      formControlName="passport"
      class="form-control"
      [class.invalid]="isFieldInvalid(passengerForm.controls.passport)"
    />
    @if (isFieldInvalid(passengerForm.controls.passport)) {
      <span class="error-message">
        {{ getFieldError(passengerForm.controls.passport) }}
      </span>
    }
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Lógica de Validação no Component

// Verificar se campo está inválido
isFieldInvalid(control: AbstractControl<PassengerDetailForm>): boolean {
  return !!(control && control.invalid && (control.dirty || control.touched));
}

// Obter mensagem de erro apropriada
getFieldError(control: AbstractControl<PassengerDetailForm>): string {
  if (!control || !control.errors) return '';

  if (control.errors['required']) return 'This field is required';
  if (control.errors['minlength']) {
    return `Minimum length is ${control.errors['minlength'].requiredLength}`;
  }
  if (control.errors['pattern']) return 'Invalid format';
  if (control.errors['min']) {
    return `Minimum value is ${control.errors['min'].min}`;
  }

  return 'Invalid field';
}
Enter fullscreen mode Exit fullscreen mode

Problemas e Limitações dos Reactive Forms

Após trabalhar com Reactive Forms em projetos complexos, alguns problemas se tornam mais escancarados no nosso dia a dia:

  1. Boilerplate Excessivo: Muito código para criar e gerenciar formulários
  2. Gerenciamento de Subscrições: takeUntil, Subject, ngOnDestroy em todo lugar
  3. Tipos Duplicados: FormControl<string> vs string - duplicação de tipo entre modelo e formulário
  4. Validação Espalhada: Validadores em múltiplos lugares (criação do form, validadores customizados, template)
  5. Mensagens de Erro Manuais: Lógica repetitiva de mapeamento de erros para mensagens
  6. Reatividade Manual: Precisa subscrever manualmente a valueChanges para reagir
  7. Complexidade de FormArray: Adicionar/remover itens requer muita cerimônia

gato cansado


Forms/Signal: A Nova Era

O Que É Signal Forms?

Lançado experimentalmente no Angular 21, o Signal Forms (@angular/forms/signal) é uma reimaginação completa da API de formulários, projetada para trabalhar nativamente com o sistema de signals em Angular.

Principais diferenças filosóficas:

Aspecto Reactive Forms Signal Forms
Fonte de verdade FormGroup/FormControl Signal com modelo de domínio
Validação Validators em cada control Schema centralizado
Reatividade RxJS Observables Signals nativos
Tipos Tipos de form específicos Tipos de domínio diretos
Template binding formControlName [field] directive

O Que Signal Forms Resolve?

Signal Forms ataca diretamente alguns dos pontos mais dolorosos dos Reactive Forms tradicionais:

1. Eliminação do Boilerplate

Não é mais necessário:

  • Importar e injetar FormBuilder ou NonNullableFormBuilder
  • Criar tipos específicos de formulário (FormControl<T>, FormGroup<T>)
  • Gerenciar subscrições manualmente
  • Menos código para manter e menos pontos de falha.

A primeira impressão costuma ser: “isso parece simples demais”.

Normalmente é o momento em que algo bom está acontecendo.


2. Modelo como Fonte de Verdade

// Modelo de domínio limpo - não tipos de formulário!
protected readonly bookingModel = signal<FlightBooking>({
  flightDetails: defaultFlightDetails(),
  passengerDetails: [],
  services: defaultServices(),
  payment: defaultPaymentBase()
});

// Form criado A PARTIR do modelo
protected readonly bookingForm = form(this.bookingModel, (schemaPath) => {
  // Validação aqui
});
Enter fullscreen mode Exit fullscreen mode

Nessa etapa, o "form" recebe o signal como primeiro argumento, e então é passado um callback chamado schemaPath (SchemaPathTree), onde será possível acessar os objetos do formulário.

Se você está procurando onde ficou a sincronização manual, ela não ficou.

3. Validação Centralizada e Declarativa

Toda a lógica de validação em um único lugar, com mensagens inline:

protected readonly bookingForm = form(this.bookingModel, (schemaPath) => {
  const { flightDetails, passengerDetails, services, payment } = schemaPath;

  // Validações simples com mensagens inline
  required(flightDetails.origin, { message: 'Origin is required' });
  minLength(flightDetails.origin, 3, {
    message: 'Origin must be at least 3 characters'
  });

  required(flightDetails.destination, { message: 'Destination is required' });
  minLength(flightDetails.destination, 3, {
    message: 'Destination must be at least 3 characters'
  });

  required(flightDetails.departureDate, {
    message: 'Departure date is required'
  });

  // Validação cross-field
  validate(flightDetails.returnDate, ({ value, valueOf }) => {
    const flightType = valueOf(flightDetails.flightType);
    const returnDate = value();
    const departureDate = valueOf(flightDetails.departureDate);

    if (flightType === FLIGHT_TYPE.ROUNDTRIP) {
      if (!returnDate) {
        return {
          kind: 'returnDateRequired',
          message: 'Return date is required for round trip flights'
        };
      }

      if (departureDate && returnDate && new Date(returnDate) < new Date(departureDate)) {
        return {
          kind: 'returnDateInvalid',
          message: 'Return date must be after departure date'
        };
      }
    }

    return null;
  });

  // Validação de arrays
  const passengerSchema = schema<PassengerDetail>((passengerPath) => {
    required(passengerPath.firstName, { message: 'First name is required' });
    minLength(passengerPath.firstName, 2, {
      message: 'First name must be at least 2 characters'
    });

    required(passengerPath.lastName, { message: 'Last name is required' });
    minLength(passengerPath.lastName, 2, {
      message: 'Last name must be at least 2 characters'
    });

    required(passengerPath.dateOfBirth, {
      message: 'Date of birth is required'
    });

    required(passengerPath.gender, { message: 'Gender is required' });

    required(passengerPath.passport, { message: 'Passport is required' });
    pattern(passengerPath.passport, /^[A-Z0-9]{6,9}$/, {
      message: 'Passport must be 6-9 alphanumeric characters',
    });

    required(passengerPath.nationality, { message: 'Nationality is required' });
  });

  applyEach(schemaPath.passengerDetails, passengerSchema);
});
Enter fullscreen mode Exit fullscreen mode

Você pode ter notado algo interessante: os validadores como required, min, max e minLength possuem o mesmo nome dos atributos HTML nativos.

Ao usar esses validadores, o forms/signal reflete automaticamente esses atributos no field correspondente.
Ao declarar manualmente um atributo como max no template HTML em um campo que utiliza a diretiva field, o Angular lança o seguinte erro:

Imagem contendo o erro que Angular lança quando tentamos colocar um atributo numa tag HTML na qual usamos a diretiva

4. Reatividade Automática com Signals

// Computed values automáticos - sem subscrições!
protected readonly totalPassengers = computed(() => {
  const passengers = this.bookingModel().flightDetails.passengers;
  return passengers.adults + passengers.children;
});

// Effect para reagir a mudanças - ainda sem subscrições manuais!
constructor() {
  effect(() => {
    const model = this.bookingModel();
    const totalPassengers =
      model.flightDetails.passengers.adults +
      model.flightDetails.passengers.children;

    this.updatePassengerDetails(totalPassengers);
  });

  effect(() => {
    const paymentMethod = this.bookingModel().payment.paymentMethod;
    // Atualiza modelo baseado no método de pagamento
  });
}
Enter fullscreen mode Exit fullscreen mode

Migração: aplicando Signal Forms passo a passo

O primeiro passo da migração envolve o modelo.

Antes de pensar em validação ou template, precisamos separar o "domínio" da "infraestrutura" de formulário.

Passo 1: Converter FormGroups em Modelos de Domínio

Antes (Reactive Forms):

// Tipos específicos de formulário reativo
export interface FlightDetailsForm {
  flightType: FormControl<'roundtrip' | 'oneway'>;
  origin: FormControl<string>;
  destination: FormControl<string>;
  departureDate: FormControl<string>;
  returnDate: FormControl<string>;
  passengers: FormGroup<PassengersForm>;
  class: FormControl<FlightClass>;
}

export interface PassengersForm {
  adults: FormControl<number>;
  children: FormControl<number>;
}
Enter fullscreen mode Exit fullscreen mode

Depois (Signal Forms):

// Tipos de domínio, sem acoplamento ao formulário
export interface FlightDetails {
  flightType: FlightType;
  origin: string;
  destination: string;
  departureDate: string;
  returnDate: string;
  passengers: Passengers;
  class: FlightClass;
}

export interface Passengers {
  adults: number;
  children: number;
}
Enter fullscreen mode Exit fullscreen mode

Passo 2: Substituir FormBuilder por Signal + Form

Antes:

export class FlightSearchComponent implements OnInit, OnDestroy {
  private readonly fb = inject(NonNullableFormBuilder);
  private readonly destroy$ = new Subject<void>();

  bookingForm = this.fb.group<BookingForm>({
    flightDetails: this.createFlightDetailsGroup(),
    passengerDetails: this.fb.array<FormGroup<PassengerDetailForm>>([]),
    services: this.createServicesGroup(),
    payment: this.createPaymentGroup()
  });

  private createFlightDetailsGroup(): FormGroup<FlightDetailsForm> {
    return this.fb.group<FlightDetailsForm>({
      flightType: this.fb.control(FLIGHT_TYPE.ROUNDTRIP),
      origin: this.fb.control('', {
        validators: [Validators.required, Validators.minLength(3)],
      }),
      // ... mais 20 linhas de criação de form
    });
  }

  ngOnInit(): void {
    this.bookingForm.controls.flightDetails.valueChanges
      .pipe(takeUntil(this.destroy$))
      .subscribe(/* ... */);
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}
Enter fullscreen mode Exit fullscreen mode

Depois:

export class SearchFlightComponent {
  // Signal com modelo de domínio
  protected readonly bookingModel = signal<FlightBooking>({
    flightDetails: defaultFlightDetails(),
    passengerDetails: [],
    services: defaultServices(),
    payment: defaultPaymentBase()
  });

  // Form criado a partir do modelo, com validação declarada em um único lugar
  protected readonly bookingForm = form(this.bookingModel, (schemaPath) => {
    // Todo schema de validação aqui
    const { flightDetails } = schemaPath;

    required(flightDetails.origin, { message: 'Origin is required' });
    minLength(flightDetails.origin, 3, {
      message: 'Origin must be at least 3 characters'
    });
    // ... validações declarativas
  });
}
Enter fullscreen mode Exit fullscreen mode

Com o modelo isolado, o formulário deixa de ser o centro da aplicação.
Ele passa a ser apenas uma projeção reativa desse estado.

Passo 3: Migrar Validação para Schema

Padrão de Migração:

// ANTES: Validators espalhados
origin: this.fb.control('', {
  validators: [Validators.required, Validators.minLength(3)],
})

// DEPOIS: Validação no schema com mensagens
required(flightDetails.origin, { message: 'Origin is required' });
minLength(flightDetails.origin, 3, {
  message: 'Origin must be at least 3 characters'
});
Enter fullscreen mode Exit fullscreen mode

Nesse ponto da migração, normalmente bate aquela sensação de alívio:
Frieren thumbs up com a palavra ganbare escrita

Validadores Customizados:

// ANTES: Método separado
private returnDateValidator(control: AbstractControl): ValidationErrors | null {
  const flightType = control.get('flightType')?.value;
  const returnDate = control.get('returnDate')?.value;
  // ... lógica complexa
  return { returnDateRequired: true };
}

// DEPOIS: Inline no schema com acesso a outros valores
validate(flightDetails.returnDate, ({ value, valueOf }) => {
  const flightType = valueOf(flightDetails.flightType);
  const returnDate = value();
  const departureDate = valueOf(flightDetails.departureDate);

  if (flightType === FLIGHT_TYPE.ROUNDTRIP) {
    if (!returnDate) {
      return {
        kind: 'returnDateRequired',
        message: 'Return date is required for round trip flights'
      };
    }

    if (departureDate && returnDate && new Date(returnDate) < new Date(departureDate)) {
      return {
        kind: 'returnDateInvalid',
        message: 'Return date must be after departure date'
      };
    }
  }

  return null;
});
Enter fullscreen mode Exit fullscreen mode

Passo 4: Migrar FormArray para Array no Modelo

Antes:

// FormArray complexo
passengerDetails: this.fb.array<FormGroup<PassengerDetailForm>>([])

// Adicionar item
this.passengerDetailsArray.push(this.createPassengerDetailGroup(type));

// Remover item
this.passengerDetailsArray.removeAt(index);

// Acessar no template
@for (let passenger of passengerDetailsArray.controls; track $index) {
  <div [formGroupName]="$index">
    <!-- campos -->
  </div>
}
Enter fullscreen mode Exit fullscreen mode

Ao remover o FormArray, o gerenciamento deixa de ser imperativo e passa a ser apenas atualização de estado.

Depois:

// Array simples no modelo
passengerDetails: PassengerDetail[]

// Adicionar item - update direto no modelo!
this.bookingModel.update((model) => ({
  ...model,
  passengerDetails: [
    ...model.passengerDetails,
    createDefaultPassenger(type)
  ]
}));

// Remover item
this.bookingModel.update((model) => ({
  ...model,
  passengerDetails: model.passengerDetails.filter((_, i) => i !== index)
}));

// Acessar no template - muito mais simples!
@for (passenger of bookingForm().passengerDetails; track $index) {
  <div>
    <!-- campos com [field] directive -->
  </div>
}
Enter fullscreen mode Exit fullscreen mode

Validação de Arrays:

// Schema reutilizável para cada item do array
const passengerSchema = schema<PassengerDetail>((passengerPath) => {
  required(passengerPath.firstName, { message: 'First name is required' });
  minLength(passengerPath.firstName, 2, {
    message: 'First name must be at least 2 characters'
  });
  required(passengerPath.lastName, { message: 'Last name is required' });
  // ... mais validações
});

// Aplicar schema a cada item do array
applyEach(schemaPath.passengerDetails, passengerSchema);
Enter fullscreen mode Exit fullscreen mode

Passo 5: Atualizar Templates

Antes (Reactive Forms):

<form [formGroup]="passengerDetailsArray.at($index)">
  <div class="form-group">
    <label [for]="'firstName-' + $index">First Name *</label>
    <input
      [id]="'firstName-' + $index"
      type="text"
      formControlName="firstName"
      [class.invalid]="isFieldInvalid(getPassengerControl($index, 'firstName'))"
    />
    @if (isFieldInvalid(getPassengerControl($index, 'firstName'))) {
      <span class="error-message">
        {{ getFieldError(getPassengerControl($index, 'firstName')) }}
      </span>
    }
  </div>
</form>
Enter fullscreen mode Exit fullscreen mode

Depois (Signal Forms):

<!-- Muito mais limpo e declarativo! -->
@let firstNameField = passenger.firstName;

<div class="form-group">
  <label [for]="'firstName-' + $index">First Name *</label>
  <input
    [id]="'firstName-' + $index"
    type="text"
    [field]="firstNameField"
    [class.invalid]="isFieldInvalid(firstNameField)"
  />
  @if (isFieldInvalid(firstNameField)) {
    @for (error of firstNameField().errors(); track $index) {
      <app-form-error-message>
        {{ error.message }}
      </app-form-error-message>
    }
  }
</div>
Enter fullscreen mode Exit fullscreen mode

Utilitários de Validação Reutilizáveis:

// shared/utils/form-validation.utils.ts
export function isFieldInvalid(
  field: FieldTree<unknown, string> | FieldState<unknown, string>
): boolean {
  const fieldState = typeof field === 'function' ? field() : field;
  return fieldState.invalid() && (fieldState.dirty() || fieldState.touched());
}

export function getFieldError(
  field: FieldTree<unknown, string> | FieldState<unknown, string>
): string | null {
  const fieldState = typeof field === 'function' ? field() : field;
  const errors = fieldState.errors();
  if (!errors) return null;

  const firstKey = Object.keys(errors)[0];
  const error = errors[firstKey];

  if (error && typeof error === 'object' && 'message' in error) {
    return (error as { message: string }).message;
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Tratamento de Erros com Signal Forms

Erros Nativos no Template

Uma das grandes vantagens dos Signal Forms é que os erros são objetos tipados e acessíveis diretamente no template, tornando o tratamento de erros muito mais simples e declarativo.

Estrutura de Erros

// Cada field é um signal que retorna FieldState
const firstNameField = passenger.firstName;

// FieldState contém:
interface FieldState<T, K extends string> {
  value(): T;
  invalid(): boolean;
  valid(): boolean;
  pending(): boolean;
  disabled(): boolean;
  touched(): boolean;
  dirty(): boolean;
  errors(): Record<string, any> | null;
}
Enter fullscreen mode Exit fullscreen mode

Exibição de Erros no Template

@let firstNameField = passenger.firstName;

<input
  [id]="'firstName-' + $index"
  type="text"
  [field]="firstNameField"
  [class.invalid]="firstNameField().invalid() && firstNameField().touched()"
  [attr.aria-invalid]="firstNameField().invalid()"
  [attr.aria-describedby]="firstNameField().invalid() ? 'firstName-' + $index + '-error' : null"
/>

<!-- Iteração sobre todos os erros -->
@if (firstNameField().invalid() && firstNameField().touched()) {
  <div [id]="'firstName-' + $index + '-error'" role="alert">
    @for (error of firstNameField().errors(); track $index) {
      <app-form-error-message>
        {{ error.message }}
      </app-form-error-message>
    }
  </div>
}
Enter fullscreen mode Exit fullscreen mode

Atributos ARIA Dinâmicos

Como os Signal Forms expõem o estado de forma reativa, é fácil adicionar atributos de acessibilidade:

<input
  [field]="emailField"
  [attr.aria-invalid]="emailField().invalid()"
  [attr.aria-required]="true"
  [attr.aria-describedby]="emailField().invalid() ? 'email-error' : null"
/>

@if (emailField().invalid() && emailField().touched()) {
  <span id="email-error" role="alert" class="error-message">
    @for (error of emailField().errors(); track $index) {
      <span>{{ error.message }}</span>
    }
  </span>
}
Enter fullscreen mode Exit fullscreen mode

Componente de Erro Reutilizável

Criar um componente reutilizável para exibição de erros torna o código mais consistente:

// shared/components/form-error-message/form-error-message.component.ts
import { Component, input } from '@angular/core';

@Component({
  selector: 'app-form-error-message',
  standalone: true,
  template: `
    <span
      class="error-message"
      role="alert"
      [id]="uniqueId()"
    >
      <ng-content></ng-content>
    </span>
  `,
  styles: [`
    .error-message {
      color: var(--error-color, #dc3545);
      font-size: 0.875rem;
      margin-top: 0.25rem;
      display: block;
    }
  `]
})
export class FormErrorMessageComponent {
  uniqueId = input<string>('');
}
Enter fullscreen mode Exit fullscreen mode

Uso:

@if (firstNameField().invalid() && firstNameField().touched()) {
  @for (error of firstNameField().errors(); track $index) {
    <app-form-error-message [uniqueId]="'firstName-error-' + $index">
      {{ error.message }}
    </app-form-error-message>
  }
}
Enter fullscreen mode Exit fullscreen mode

Erros Customizados com Validadores

// Validador customizado para email
validate(payment.pixDetails.email, ({ value }) => {
  const email = value();
  if (!email) return null;

  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(email)) {
    return {
      kind: 'invalidEmail',
      message: 'Please enter a valid email address'
    };
  }

  return null;
});

// Validador com lógica complexa para CPF
validate(payment.pixDetails.cpf, ({ value }) => {
  const cpf = value();
  if (!cpf) return null;

  // Remove caracteres não numéricos
  const cleanCpf = cpf.replace(/\D/g, '');

  if (cleanCpf.length !== 11) {
    return {
      kind: 'invalidCpfLength',
      message: 'CPF must have 11 digits'
    };
  }

  // Validação de dígitos verificadores
  if (!isValidCPF(cleanCpf)) {
    return {
      kind: 'invalidCpf',
      message: 'Invalid CPF number'
    };
  }

  return null;
});
Enter fullscreen mode Exit fullscreen mode

Casos de Uso

Integração com APIs Externas (ViaCEP)

Trabalhar com APIs externas para preencher dados automaticamente:

Reactive Forms (Antes):

onCepChange(cep: string): void {
  if (cep.length === 8) {
    this.viaCepService.getAddress(cep)
      .pipe(takeUntil(this.destroy$))
      .subscribe({
        next: (address) => {
          const boletoControl = this.bookingForm.controls.payment
            .get('boletoDetails') as FormGroup;

          boletoControl.patchValue({
            street: address.logradouro,
            neighborhood: address.bairro,
            city: address.localidade,
            state: address.uf
          });
        },
        error: (error) => {
          console.error('Error fetching address:', error);
        }
      });
  }
}
Enter fullscreen mode Exit fullscreen mode

Signal Forms (Depois):

async onCepChange(cep: string): Promise<void> {
  if (cep.length === 8) {
    try {
      const address = await firstValueFrom(
        this.viaCepService.getAddress(cep)
      );

      // Atualização direta no modelo!
      this.bookingModel.update((model) => ({
        ...model,
        payment: {
          ...model.payment,
          boletoDetails: {
            ...model.payment.boletoDetails!,
            address: {
              ...model.payment.boletoDetails!.address,
              street: address.logradouro,
              neighborhood: address.bairro,
              city: address.localidade,
              state: address.uf
            }
          }
        }
      }));
    } catch (error) {
      console.error('Error fetching address:', error);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Formulários Condicionais (Nested Dynamic Forms)

Criar formulários que mudam estrutura baseado em condições:

// Schema para payment com sub-formulários condicionais
const { payment } = schemaPath;

required(payment.paymentMethod, { message: 'Payment method is required' });

// Validação condicional baseada no método de pagamento
validate(payment, ({ valueOf }) => {
  const method = valueOf(payment.paymentMethod);

  switch (method) {
    case PAYMENT_METHOD.CREDIT_CARD:
    case PAYMENT_METHOD.DEBIT_CARD:
      // Valida apenas se cardDetails existe
      const cardDetails = valueOf(payment.cardDetails);
      if (!cardDetails) {
        return {
          kind: 'cardDetailsRequired',
          message: 'Card details are required'
        };
      }
      break;

    case PAYMENT_METHOD.PIX:
      const pixDetails = valueOf(payment.pixDetails);
      if (!pixDetails) {
        return {
          kind: 'pixDetailsRequired',
          message: 'PIX details are required'
        };
      }
      break;

    case PAYMENT_METHOD.BOLETO:
      const boletoDetails = valueOf(payment.boletoDetails);
      if (!boletoDetails) {
        return {
          kind: 'boletoDetailsRequired',
          message: 'Boleto details are required'
        };
      }
      break;
  }

  return null;
});

// Sub-schemas para cada tipo de pagamento
if (this.bookingModel().payment.cardDetails) {
  const cardSchema = schema<CardDetails>((cardPath) => {
    required(cardPath.cardNumber, { message: 'Card number is required' });
    pattern(cardPath.cardNumber, /^\d{16}$/, {
      message: 'Card number must be 16 digits'
    });

    required(cardPath.cardholderName, {
      message: 'Cardholder name is required'
    });

    required(cardPath.expirationDate, {
      message: 'Expiration date is required'
    });
    pattern(cardPath.expirationDate, /^\d{2}\/\d{2}$/, {
      message: 'Expiration date must be in MM/YY format'
    });

    required(cardPath.cvv, { message: 'CVV is required' });
    pattern(cardPath.cvv, /^\d{3,4}$/, {
      message: 'CVV must be 3 or 4 digits'
    });
  });

  // Aplicar schema ao cardDetails se existir
  applySchema(payment.cardDetails, cardSchema);
}
Enter fullscreen mode Exit fullscreen mode

Computed Values e Derived State

Criar valores calculados automaticamente baseados no estado do formulário:

// Total de passageiros
protected readonly totalPassengers = computed(() => {
  const passengers = this.bookingModel().flightDetails.passengers;
  return passengers.adults + passengers.children;
});

Enter fullscreen mode Exit fullscreen mode

Tabela Comparativa: Reactive Forms vs Signal Forms

Aspecto Reactive Forms Signal Forms
Importações ReactiveFormsModule, FormBuilder, FormGroup, FormControl, FormArray, Validators @angular/forms/signal, form, schema, validadores específicos
Definição de Modelo Tipos específicos (FormControl<T>, FormGroup<T>) Interfaces de domínio limpas
Criação de Form this.fb.group<T>({ ... }) com validadores inline form(signal, schema) com validação centralizada
Validação Validators.required, Validators.minLength(n) required(field, { message }), minLength(field, n, { message })
Mensagens de Erro Mapeamento manual de errors para strings Mensagens inline no schema, tipadas
Template Binding formControlName="field" [field]="fieldSignal"
Acesso a Valores form.get('field')?.value model().field
Mudança de Valores form.patchValue({ ... }) model.update(m => ({ ...m, ... }))
FormArray FormArray com push(), removeAt(), controls Array no modelo, manipulação direta
Validação de Arrays Validadores em cada item ao criar applyEach(path, schema)
Reatividade valueChanges.subscribe() effect() ou computed()
Gerenciamento de Subscrições takeUntil(destroy$), ngOnDestroy Não necessário
Estado de Campo control.invalid, control.touched, control.dirty field().invalid(), field().touched(), field().dirty()
Erros control.errors (Record ou null) field().errors() (array tipado)
Validação Cross-Field Validador customizado no FormGroup validate(field, ({ valueOf }) => ...)
Validação Async AsyncValidatorFn validateAsync(field, async ({ value }) => ...)
Disabled Fields control.disable(), control.enable() field().disabled() (readonly state)
Reset form.reset() model.set(defaultValue)
Type Safety Moderada (genéricos complexos) Forte (inferência do modelo)
Boilerplate Alto (FormBuilder, tipos, subscrições) Baixo (modelo + schema)
Curva de Aprendizado Moderada (conceitos RxJS) Íngreme inicialmente (novo paradigma)
Performance Boa (com OnPush) Excelente (signals nativos, zoneless)
Compatibilidade Angular 2+ Angular 21+ (experimental)

Conclusão

A migração de Reactive Forms para Signal Forms no Angular 21+ representa mais do que uma simples atualização de API - é uma mudança fundamental de paradigma na forma como pensamos e construímos formulários.

Principais Benefícios Observados

  1. Redução Drástica de Boilerplate

    • Eliminação de 60-70% do código de setup
    • Não mais FormBuilder, ngOnDestroy, gerenciamento de subscrições
    • Modelos de domínio limpos sem tipos específicos de formulário
  2. Validação Centralizada e Declarativa

    • Todo schema de validação em um único lugar
    • Mensagens de erro co-locadas com regras
    • Validação cross-field simplificada
    • Reutilização de schemas com applyEach
  3. Reatividade Nativa

    • computed() para valores derivados
    • effect() para side-effects caso necessário
    • Sem gerenciamento manual de subscrições
    • Performance superior com zoneless change detection
  4. Developer Experience Melhorada

    • Type safety forte com inferência automática
    • Mensagens de erro tipadas e acessíveis
    • Templates mais limpos e legíveis
    • Debugging mais fácil (signals são rastreáveis)
  5. Preparação para o Futuro

    • Alinhado com a direção do Angular (signals em todo lugar)
    • Compatível com zoneless mode
    • Base para futuras features do framework

Desafios e Considerações

Status Experimental:

  • A API ainda é experimental e pode mudar
  • Nem toda funcionalidade dos Reactive Forms tem paridade
  • Documentação ainda está em desenvolvimento

Curva de Aprendizado:

  • Paradigma diferente
  • Equipe precisa se familiarizar com signals e o novo modelo
  • Pode ser confuso manter dois sistemas em paralelo

Ecossistema:

  • Bibliotecas de terceiros podem não ter suporte ainda
  • Exemplos e recursos da comunidade ainda são limitados
  • Ferramentas de dev (extensões, linters) podem não estar atualizadas

Reflexões Finais

Signal Forms representa uma evolução natural do Angular em direção a um modelo de reatividade mais simples e performático. A jornada de migração documentada neste artigo mostra que, apesar da curva de aprendizado inicial, os benefícios em termos de simplicidade, performance e maintainability fazem do Signal Forms uma adição valiosa ao ecossistema Angular.

O futuro dos formulários no Angular é reactivo por natureza, mas gerenciado por signals - e esse futuro já começou. Para desenvolvedores que buscam criar aplicações modernas, performáticas e de fácil manutenção, Signal Forms oferece uma base sólida e promissora.


Referências


Sobre o Projeto

Este artigo documenta a implementação real disponível em:

  • /flight-booking-legacy/ - Reactive Forms
  • /flight-booking/ - Signal Forms

Código completo, testes e documentação adicional disponíveis no repositório.

Top comments (0)