Angular 21 - Formulários reativos com Signal Forms
Veja a nova abordagem do framework Angular para lidar com formulários de forma reativa com o Signal Forms.
O Signal Forms, que vem sendo desenvolvido pelo time do Angular tem sido aprimorado nas versões seguintes e representa uma das funcionalidades mais aguardadas do framework — os formulários baseados em Signals. Esse recurso finalmente começa a resolver problemas que têm sido um incômodo por anos.
Por que isso é tão importante? Porque os Signal Forms finalmente resolvem problemas que têm sido um incômodo por anos:
- A configuração verbosa de
FormGroupeFormControl. - As assinaturas manuais de
valueChangespara manter a interface (UI) sincronizada. - Lógica de validação duplicada em vários campos.
Neste texto, vou apresentar um exemplo de formulário e mostrar, passo a passo, como os Signal Forms simplificam o código e em relação ao antigo [FormGroup] e [FormControl]. Vou criar o exemplo utilizando a versão 21-rc (release candidate) que foi publicada recentemente.
Requisitos para replicar o exemplo
Para criar o cenário de estudo foram utilizadas as seguintes versões do Node.js e Angular:
# Node.js v24.11.0
# Para conferir a versão do Node.js
# abra o terminal e digite:
node -v
# Para criar o projeto Angular 21rc
# utilizei o seguinte comando:
#
# Observação: optei em não instalar o CLI
# em modo global.
npx @angular/[email protected] new angular21rc
# As opções que selecionei:
✔ Which stylesheet system would you like to use?
CSS [https://developer.mozilla.org/docs/Web/CSS]
✔ Do you want to enable Server-Side Rendering
(SSR) and Static Site Generation (SSG/Prerendering)?
No
✔ Which AI tools do you want to configure with Angular
best practices? https://angular.dev/ai/develop-with-ai
None
Testando o projeto recém criado
Abrindo o diretório do projeto é só executar o comando npm start para executar. Então abra o navegador e vá até o endereço localhost:4200 para testar:

Passo 1 - Configurando o formulário com a função form()
Na nova API Signal Forms do Angular o ponto de partida é a função form() . Em vez de construir manualmente instâncias de FormGroup e FormControl, você simplesmente fornece a ela um modelo de signal (o estado do seu formulário) e o Angular cria um formulário baseado em signals para você.
Vale mencionar que, tradicionalmente, a validação de formulários era através do uso do FormGroup e FormControl, conforme apresentado no seguinte post:

Exemplo usando o form para criar um formulário:
import { JsonPipe } from '@angular/common';
import { Component, signal } from '@angular/core';
import { Field, form } from '@angular/forms/signals';
import { RouterOutlet } from '@angular/router';
interface Cliente {
nome: string;
email: string;
dataAdesao: Date;
}
@Component({
selector: 'app-root',
imports: [Field, JsonPipe, RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected readonly cliente = signal<Cliente>({
nome: 'José',
email: '',
dataAdesao: new Date(),
});
protected readonly formularioCliente = form(this.cliente);
salvar() {
console.log('Salvar');
}
}
app.ts
No template usamos o [field] ao invés do antigo formControl para vincular um campo do formulário a um input:
<h2>Cadastro de Cliente</h2>
<form (submit)="salvar(); $event.preventDefault()">
<div>
<input
[field]="formularioCliente.nome"
placeholder="nome..."
type="text" />
</div>
<div>
<input
[field]="formularioCliente.email"
placeholder="email..."
type="email" />
</div>
<div>
<input
[field]="formularioCliente.dataAdesao"
type="date" />
</div>
<button type="submit">Salvar</button>
</form>
<hr />
<div>
<strong>formularioCliente().value():</strong>
<pre>{{ formularioCliente().value() | json }}</pre>
</div>
<div>
<strong>formularioCliente().valid() :</strong>
<pre>{{ formularioCliente().valid() | json }}</pre>
</div>
<router-outlet />
app.html
Veja no resultado abaixo que os valores iniciais definidos na classe já aparecem nos inputs:
Formulário renderizado no navegador
Perceba que conforme o usuário interage com o formulário digitando os valores, o formularioCliente().value() vai sendo alterado.
Passo 2 - Aplicando validações no formulário
Evoluindo um pouco o exemplo, vamos adicionar algumas regras de validação dentro da função form():
import { JsonPipe } from '@angular/common';
import { Component, signal } from '@angular/core';
import {
customError,
email,
Field,
form,
maxLength,
minLength,
required,
submit,
validate,
} from '@angular/forms/signals';
import { RouterOutlet } from '@angular/router';
interface Cliente {
nome: string;
email: string;
dataAdesao: Date;
}
@Component({
selector: 'app-root',
imports: [Field, JsonPipe, RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected readonly status = signal<string>('');
protected readonly cliente = signal<Cliente>({
nome: 'José',
email: '',
dataAdesao: new Date(),
});
protected readonly formularioCliente = form(
this.cliente,
(path) => {
// ****************************************
// Nome
// ****************************************
required(path.nome),
minLength(path.nome, 2),
maxLength(path.nome, 10),
// ****************************************
// Email
// ****************************************
required(path.email),
email(path.email),
// ****************************************
// Data adesão
// ****************************************
required(path.dataAdesao),
validate(path.dataAdesao, (ctx) => {
const dataInformada = ctx.value();
if (!dataInformada) {
return null;
}
const dataMinima = new Date(
'2025-12-01T12:00:00.000-0300'
);
if (dataInformada < dataMinima) {
const mensagem =
`A data deve ser a partir de
${dataMinima.toLocaleDateString()}`;
return customError({
kind: 'minDate',
message: mensagem,
});
}
return null;
});
}
);
salvar() {
console.log('Salvar');
}
}
app.ts
Uma vez configuradas as validações dentro do form(), é possível obter o status de validação dentro dos templates. Para fazer isto basta acessar os erros de cada campo da seguinte forma:
formularioCliente.nome().errors();Além de obter as mensagens de erro de validação de cada campo, é possível identificar o tipo de erro de validação através da propriedade kind e assim mostrar uma mensagem de erro específica. Também é possível adicionar as mensagens na configuração do form() como é o caso do campo dataAdesao.
Adicionando as mensagens de validação na tela o template fica da seguinte forma:
<h2>Cadastro de Cliente</h2>
<form (submit)="salvar(); $event.preventDefault()">
<div>
<input
[field]="formularioCliente.nome"
placeholder="nome..."
type="text"
/>
</div>
@let errosNome = formularioCliente.nome().errors();
@if (errosNome) {
<div style="color: red">
@for (error of errosNome; track error){
@if (error.kind === 'required') {
Informe um nome
} @else if (error.kind === 'minLength'
&& 'minLength' in error) {
Informe um nome com ao menos
<strong>{{ error.minLength }}</strong> caracteres
} @else if (error.kind === 'maxLength'
&& 'maxLength' in error) {
Informe um nome com no máximo
<strong>{{ error.maxLength }}</strong> caracteres
}
}
</div>
}
<div>
<input
[field]="formularioCliente.email"
placeholder="email..."
type="email"
autocomplete="off"
/>
</div>
<div style="color: red">
@let errosEmail = formularioCliente.email().errors();
@for (error of errosEmail; track error){
@if (error.kind === 'required') {
Informe um email
} @else if (error.kind === 'email') {
Informe um email válido
} @else if (error.kind === 'server') {
Esse e-mail não está mais disponível
}
}
</div>
<div>
<input
[field]="formularioCliente.dataAdesao"
type="date"
/>
</div>
<div style="color: red">
@let errosDataAdesao = formularioCliente
.dataAdesao()
.errors();
@for (error of errosDataAdesao; track error) {
@if (error.kind === 'required') {
Informe uma data de adesão
} @else if (error.kind === 'minDate'
&& 'message' in error) {
{{ error.message }}
}
}
</div>
<button type="submit">Salvar</button>
</form>
<hr />
<div>
<strong>formularioCliente().value():</strong>
<pre>{{ formularioCliente().value() | json }}</pre>
</div>
<div>
<strong>formularioCliente().valid() :</strong>
<pre>{{ formularioCliente().valid() | json }}</pre>
</div>
<router-outlet />
app.html
Veja o resultado no pequeno video a seguir:
Passo 3 - efetuando o submit do formulário
Agora que já vimos como construir um formulário e aplicar validações, é hora de entender como funciona o processo de envio (submit).
O Signal Forms simplificou bastante essa etapa com a função submit().
Com ela, é possível, por exemplo:
- Identificar o estado de envio diretamente no formulário;
- Tratar erros retornados pelo backend e integrá-los ao sistema de erros do
form().
import { JsonPipe } from '@angular/common';
import { Component, signal } from '@angular/core';
import {
customError,
email,
Field,
form,
maxLength,
minLength,
required,
submit,
validate,
} from '@angular/forms/signals';
import { RouterOutlet } from '@angular/router';
interface Cliente {
nome: string;
email: string;
dataAdesao: Date;
}
@Component({
selector: 'app-root',
imports: [Field, JsonPipe, RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected readonly status = signal<string>('');
protected readonly cliente = signal<Cliente>({
nome: 'José',
email: '',
dataAdesao: new Date(),
});
protected readonly formularioCliente = form(
this.cliente,
(path) => {
// ****************************************
// Nome
// ****************************************
required(path.nome),
minLength(path.nome, 2),
maxLength(path.nome, 10),
// ****************************************
// Email
// ****************************************
required(path.email),
email(path.email),
// ****************************************
// Data adesão
// ****************************************
required(path.dataAdesao),
validate(path.dataAdesao, (ctx) => {
const dataInformada = ctx.value();
if (!dataInformada) {
return null;
}
const dataMinima = new Date(
'2025-12-01T12:00:00.000-0300'
);
if (dataInformada < dataMinima) {
const mensagem =
`A data deve ser a partir de
${dataMinima.toLocaleDateString()}`;
return customError({
kind: 'minDate',
message: mensagem,
});
}
return null;
});
}
);
salvar() {
this.status.set('');
submit(this.formularioCliente, async (form) => {
try {
await this.simularPost(form().value());
form().reset();
this.status.set('Dados salvos');
return undefined;
} catch (erro) {
this.status.set('Erro ao salvar dados');
// Verifica se existe um código de erro tratado
// entregue pelo backend.
// Nesse exemplo simulamos um erro tratado
// indicando que o email fornecido já está em uso.
if (
typeof erro === 'object' &&
erro !== null &&
'codigoErroBackend' in erro &&
erro.codigoErroBackend === 'abc'
) {
// Aqui registramos um erro no campo `email`
return [
{
field: this.formularioCliente.email,
kind: 'server',
message: 'Email já usado',
},
];
}
// Se chegou aqui é porque o backend não retornou
// nenhum erro tratado.
return undefined;
}
});
}
simularPost(dados: Cliente): Promise<string | object> {
return new Promise((resolve, reject) => {
setTimeout(() => {
// Simula um erro para o email abaixo:
if (dados.email === '[email protected]') {
reject({ codigoErroBackend: 'abc' });
return;
}
resolve('Dados Salvos');
}, 2_000);
});
}
}
app.ts
Template:
<h2>Cadastro de Cliente</h2>
<form (submit)="salvar(); $event.preventDefault()">
<div>
<input
[field]="formularioCliente.nome"
placeholder="nome..."
type="text" />
</div>
@let errosNome = formularioCliente.nome().errors();
@if (errosNome) {
<div style="color: red">
@for (error of errosNome; track error){
@if (error.kind === 'required') {
Informe um nome
} @else if (error.kind === 'minLength'
&& 'minLength' in error) {
Informe um nome com ao menos
<strong>{{ error.minLength }}</strong> caracteres
} @else if (error.kind === 'maxLength'
&& 'maxLength' in error) {
Informe um nome com no máximo
<strong>{{ error.maxLength }}</strong> caracteres
}
}
</div>
}
<div>
<input
[field]="formularioCliente.email"
placeholder="email..."
type="email"
autocomplete="off" />
</div>
<div style="color: red">
@let errosEmail = formularioCliente.email().errors();
@for (error of errosEmail; track error){
@if (error.kind === 'required') {
Informe um email
} @else if (error.kind === 'email') {
Informe um email válido
} @else if (error.kind === 'server') {
Esse e-mail não está mais disponível
}
}
</div>
<div>
<input
[field]="formularioCliente.dataAdesao"
type="date" />
</div>
<div style="color: red">
@let errosDataAdesao = formularioCliente
.dataAdesao()
.errors();
@for (error of errosDataAdesao; track error){
@if (error.kind === 'required') {
Informe uma data de adesão
} @else if (error.kind === 'minDate'
&& 'message' in error) {
{{ error.message }}
}
}
</div>
<button
[disabled]="!formularioCliente().valid()
|| formularioCliente().submitting()"
type="submit">
@if (formularioCliente().submitting()) {
Processando...
} @else {
Salvar
}
</button>
@if (status()) {
<div>{{ status() }}</div>
}
</form>
<hr />
<div>
<strong>formularioCliente().value():</strong>
<pre>{{ formularioCliente().value() | json }}</pre>
</div>
<div>
<strong>formularioCliente().valid() :</strong>
<pre>{{ formularioCliente().valid() | json }}</pre>
</div>
<router-outlet />
app.html
Resultado:
Considerações
O Signal Forms surge como uma solução integrada ao novo sistema de reatividade do Angular, o Signals. Por muito tempo, trabalhar com formulários no Angular significava lidar com configurações extensas de FormGroup, gerenciar assinaturas de valueChanges, duplicar lógicas de validação, entre outros.
Com o Signal Forms, todo esse processo se torna mais simples e intuitivo.
Vale mencionar que, por enquanto, o Signal Forms está em modo experimental no Angular 21 (release candidate).
Se você tem dúvidas sobre o novo sistema de reatividade do Angular, o Signals, sugiro dar uma olhada no video abaixo onde explico os detalhes do Signals e Zone.js.
