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.

Fundo predominante escuro, com o logotipo do Angular e o título Signal Forms Angular
Signal Forms - Angular

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 FormGroup e FormControl.
  • As assinaturas manuais de valueChanges para 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:

Imagem mostrando o prompt com o comando "npm start" e o navegador no endereço "localhost:4200"
Executando o projeto recém-criado

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:

Validando um formulário e exibindo mensagens de erro - Angular 2+
Este artigo tem como objetivo mostrar passo a passo como criar uma validação de formulário, exibir mensagens de erro e habilitar/desabilitar o botão de Postar de acordo com a validade do formulário.

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:

0:00
/0:13

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:

0:00
/0:13

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).

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:

0:00
/0:19

Considerações

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.