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
- Introdução
- O Projeto: Sistema de Reserva de Voos
- As Etapas do Formulário
- Reactive Forms: A Abordagem Tradicional
- Forms/Signal: A Nova Era
- Migração: Da Teoria à Prática
- Tratamento de Erros com Signal Forms
- Casos de Uso Avançados
- Tabela Comparativa: Reactive Forms vs Signal Forms
- Conclusão
- 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) oufield(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:
-
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
-
PIX:
- CPF
- Nome completo
-
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;
}
}
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;
}
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),
});
}
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(),
});
}
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>
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';
}
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:
- Boilerplate Excessivo: Muito código para criar e gerenciar formulários
-
Gerenciamento de Subscrições:
takeUntil,Subject,ngOnDestroyem todo lugar -
Tipos Duplicados:
FormControl<string>vsstring- duplicação de tipo entre modelo e formulário - Validação Espalhada: Validadores em múltiplos lugares (criação do form, validadores customizados, template)
- Mensagens de Erro Manuais: Lógica repetitiva de mapeamento de erros para mensagens
-
Reatividade Manual: Precisa subscrever manualmente a
valueChangespara reagir - Complexidade de FormArray: Adicionar/remover itens requer muita cerimônia
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
FormBuilderouNonNullableFormBuilder - 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
});
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);
});
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:
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
});
}
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>;
}
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;
}
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();
}
}
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
});
}
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'
});
Nesse ponto da migração, normalmente bate aquela sensação de alívio:

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;
});
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>
}
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>
}
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);
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>
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>
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;
}
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;
}
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>
}
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>
}
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>('');
}
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>
}
}
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;
});
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);
}
});
}
}
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);
}
}
}
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);
}
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;
});
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
-
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
-
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
-
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
-
-
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)
-
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
- Angular Forms Signal Documentation
- RFC: Signal-based Forms
- Angular Blog: Introducing Signal Forms
- Angular 21 Release Notes
- Signals in Angular
- Zoneless Change Detection
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)