Validação assíncrona - AsyncValidatorFn Angular

Como criar um formulário reativo (Reactive Forms) com validação assíncrona (AsyncValidatorFn) do Angular.

Validação assíncrona - AsyncValidatorFn Angular
Validação assíncrona - AsyncValidatorFn Angular

Antes de submeter os dados para o servidor, é importante garantir que todos os dados que o usuário informou estão de acordo com os requisitos do seu negócio, ou seja, os dados são consistentes e atendem às políticas do negócio. Por esta razão, a validação no client-side é importante e já foi alvo de estudo por aqui:

Neste texto será abordado como podemos realizar validações assíncronas. Por exemplo, o usuário está preenchendo um formulário de cadastro, um dos campos é o username. Ao preencher este campo a aplicação deve validar se o username está disponível para uso através da consulta à uma API.

Para realizar esta tarefa, iremos explorar um pouco do Reactive Forms e o AsyncValidatorFn do Angular.

Estruturando o projeto

Versões utilizadas para criar o projeto:

Angular CLI: 14.2.3
Node: 16.16.0

Comandos para criar o projeto:

#Cria o diretório
mkdir consolelog-async-validator

#Entra no diretório
cd consolelog-async-validator

#Cria um novo projeto Angular
ng new projeto-async-validator

? Would you like to add Angular routing? No
? Which stylesheet format would you like to use? SCSS

Executando o projeto:

cd projeto-async-validator
npm run start
Projeto em recém criado em execução no navegador
Projeto em recém criado em execução

Configurando pacote de componentes PrimeNG

Com o projeto já configurado, poderíamos adicionar o @angular/material para criar um formulário com um design mais interessante, mas para variar um pouco vamos trabalhar com os componentes do PrimeNG. Para isto vamos adicionar os seguintes pacotes:

npm install primeng
npm install primeicons
npm install primeflex
PrimeNG is a collection of rich UI components for Angular. All widgets are open source and free to use under MIT License. PrimeNG is developed by PrimeTek Informatics, a vendor with years of expertise in developing open source UI solutions. For project news and updates, please follow us on twitter and visit our blog.

Após a instalação é necessário adicionar no arquivo angular.json algumas referências  (linhas 29 - 32):

{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "newProjectRoot": "projects",
  "projects": {
    "projeto-async-validator": {
      "projectType": "application",
      "schematics": {
        "@schematics/angular:component": {
          "style": "scss"
        }
      },
      "root": "",
      "sourceRoot": "src",
      "prefix": "app",
      "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "outputPath": "dist/projeto-async-validator",
            "index": "src/index.html",
            "main": "src/main.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "tsconfig.app.json",
            "inlineStyleLanguage": "scss",
            "assets": ["src/favicon.ico", "src/assets"],
            "styles": [
              "src/styles.scss",
              "node_modules/primeicons/primeicons.css",
              "node_modules/primeng/resources/themes/lara-light-blue/theme.css",
              "node_modules/primeng/resources/primeng.min.css",
              "node_modules/primeflex/primeflex.css"
            ],
            "scripts": []
          },
          "configurations": {},
          "defaultConfiguration": "production"
        },
        "serve": {},
        "extract-i18n": {},
        "test": {}
      }
    }
  },
  "cli": {}
}
angular.json

Observação: no arquivo acima eu removi trechos que não são relevantes para esta configuração.

Criando um formulário com validação síncrona

Para criar o formulário vamos precisar importar alguns componentes do PrimeNG, ReactiveFormsModule e vou aproveitar para já importar o HttpClientModule que será usado mais à frente:

import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';

import { ButtonModule } from 'primeng/button';
import { InputTextModule } from 'primeng/inputtext';
import { AppComponent } from './app.component';

@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    ButtonModule,
    HttpClientModule,
    InputTextModule,
    ReactiveFormsModule,
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}
app.module.ts

O formulário terá 2 campos com as seguintes regras:

  • username:
    - campo obrigatório
    -  de 3 a 10 letras minúsculas
  • email:
    - campo obrigatório
    - deve ser um e-mail válido

O código ficará da seguinte forma:

import { Component } from '@angular/core';
import {
  FormControl,
  FormGroup,
  Validators,
} from '@angular/forms';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  formGroup: FormGroup = new FormGroup({
    username: new FormControl('', [
      Validators.required,
      Validators.pattern(/^[a-z]{3,10}$/),
    ]),
    email: new FormControl('', [
      Validators.required,
      Validators.email,
    ]),
  });

  get username() {
    return this.formGroup.get('username');
  }

  get email() {
    return this.formGroup.get('email');
  }

  postar() {
    if (this.formGroup.invalid) {
      return;
    }

    console.log(this.formGroup.value);
  }
}
app.component.ts
<h1>Cadastro</h1>
<form
  class="grid"
  [formGroup]="formGroup"
  (ngSubmit)="postar()">
  <div class="col-12 md:col-6">
      
    <!-- USERNAME -->
    <div class="p-inputgroup">
      <span class="p-inputgroup-addon">
        <i class="pi pi-user"></i>
      </span>
      <input
        autofocus
        formControlName="username"
        pInputText
        placeholder="Cadastre um username"
        type="text" />
    </div>
      
    <!-- USERNAME: MENSAGENS DE VALIDAÇÃO -->
    <!-- 
         Deixei o "username && username.errors"
         na condição abaixo para evitar o uso
         do safe navigation operator (?.) Fiz
         isto porque acredito que o código ficou
         mais legível, mas é só uma opinião pessoal!
    -->
    <div
      class="p-error"
      *ngIf="
        username &&
        username.errors &&
        username.invalid &&
        (username.dirty || username.touched)
      ">
      <div *ngIf="username.errors['required']">
        Username é obrigatório
      </div>
      <div *ngIf="username.errors['pattern']">
        Este campo deve conter apenas letras
        minúsculas de 3 a 10 caracteres
      </div>
    </div>
  </div>
    
  <!-- EMAIL -->
  <div class="col-12 md:col-6">
    <div class="p-inputgroup">
      <span class="p-inputgroup-addon">
        <i class="pi pi-at"></i>
      </span>
      <input
        formControlName="email"
        pInputText
        placeholder="Informe seu e-mail"
        type="email" />
    </div>
      
    <!-- EMAIL: MENSAGENS DE VALIDAÇÃO -->
    <div
      class="p-error"
      *ngIf="
        email &&
        email.errors &&
        email.invalid &&
        (email.dirty || email.touched)
      ">
      Por favor, informe um e-mail válido
    </div>
  </div>

  <div class="col-12">
    <button
      [disabled]="formGroup.invalid"
      label="Cadastrar"
      pButton
      type="submit"></button>
  </div>
</form>
app.component.html

Executando o projeto é possível ver que o formulário está funcionando corretamente:

Testando a validação do formulário navegador
Testando a validação do formulário

Agora vamos partir para a validação assíncrona do campo username. Quando o usuário digitar um valor no campo username, o frontend precisará consultar uma API para verificar se o valor informado está disponível para uso. Para simular este cenário vamos criar uma pequena API em Node.js.

API de validação

O código abaixo tem o único propósito de criar um ambiente em que possamos validar o username, então questões como sanitização, semântica, http status code ou quaisquer outros pontos não relevantes para este contexto não foram considerados.

Basicamente o código irá pegar na URL (query string) o campo username e irá verificar se o valor é igual a consolelog. Este valor, consolelog, será o único valor disponível para uso.

import { createServer } from "node:http";
import url from "node:url";

const server = createServer((req, res) => {
 const query = url.parse(req.url, true).query;

 res.write(
  JSON.stringify({
   disponivel: query.username === "consolelog",
  })
 );

 res.end();
});

server.listen(3000, () =>
 console.log("API pronta para uso")
);
index.js
{
 "name": "api-validacao",
 "version": "1.0.0",
 "description": "",
 "main": "index.js",
 "scripts": {
  "start": "node index.js"
 },
 "keywords": [],
 "type": "module",
 "author": "",
 "license": "ISC"
}
package.json

Utilizei o "type": "module" para indicar que o formato dos módulos será tratado como ES Modules. Por isto utilizei o import ao invés do require no arquivo index.js.

The "type" field defines the module format that Node.js uses for all .js files that have that package.json file as their nearest parent.

Files ending with .js are loaded as ES modules when the nearest parent package.json file contains a top-level field "type" with a value of "module".

Link para mais detalhes.

Para testar a API usei o CURL no terminal, mas poderia ter usado um navegador qualquer:

$ npm run start

$ curl "localhost:3000?username=teste"
{"disponivel":false}                                

$ curl "localhost:3000?username=consolelog"
{"disponivel":true}

Agora vamos para a validação assíncrona no frontend.

Criando o AsyncValidator

Para realizar a validação assíncrona do campo username vamos efetuar os seguintes passos:

  1. Criar uma service para se comunicar com a API: UserService
  2. Criar uma classe com um método estático para retornar um AsyncValidatorFn
  3. Alterar o AppComponent para incluir a validação assíncrona
  4. Alterar o template do AppComponent para exibir uma mensagem ao usuário

1-UserService

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class UserService {
  constructor(private http: HttpClient) {}

  get(username: string) {
    return this.http.get<{ disponivel: boolean }>(
      `http://localhost:3000/?username=${username}`
    );
  }
}
user.service.ts

2-AppValidator

Abaixo criamos um método estático, usernameJaExiste, que recebe uma instância da UserService como parâmetro e retorna um AsyncValidatorFn. Deixei algumas explicações nos comentários ao longo do código abaixo:

import {
  AbstractControl,
  AsyncValidatorFn,
  ValidationErrors,
} from '@angular/forms';
import { map, Observable } from 'rxjs';
import { UserService } from './user.service';

export class AppValidator {
  static usernameJaExiste(
    userService: UserService
  ): AsyncValidatorFn {
    // *
    // * Repare que este método retorna
    // * um AsyncValidatorFn.
    // *
    // * No "return" abaixo temos uma função
    // * com a assinatura do AsyncValidatorFn,
    // * que é a seguinte:
    // *
    // * export declare interface AsyncValidatorFn {
    // *   (control: AbstractControl):
    // *     Promise<ValidationErrors | null> |
    // *     Observable<ValidationErrors | null>;
    // * }
    // *
    return (
      control: AbstractControl
    ): Observable<ValidationErrors | null> => {
      // *
      // * Conteúdo do método que é executado cada
      // * vez que o usuário digita algo
      // *

      // * Pega o valor do FormControl:
      const username = control.value;
      
      // * Retorna um Observable resultante
      // * da consulta à API:
      return userService.get(username).pipe(
        // * A API devolve um JSON no seguinte formato:
        // * { "disponivel": true|false }
        //
        // * Abaixo é retornado um novo valor
        // * dependendo do retorno da API.
        map((response) => {
          return response.disponivel
            ? // * Quando não há erros:
              null
            : // * Quando houver erros:
              { usernamejaexiste: username };
        })
      );
    };
  }
}
app.validator.ts

3-AppComponent

No app.component.ts tiveram duas alterações:

  1. Injeção do UserService no construtor;
  2. Inclusão do validador assíncrono no terceiro argumento do construtor do FormControl do username;
import { Component } from '@angular/core';
import {
  FormControl,
  FormGroup,
  Validators,
} from '@angular/forms';
import { AppValidator } from './app.validator';
import { UserService } from './user.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  formGroup: FormGroup = new FormGroup({
    username: new FormControl(
      '',
      [
        Validators.required,
        Validators.pattern(/^[a-z]{3,10}$/),
      ],
      // Validações assíncronas:
      [AppValidator.usernameJaExiste(this.userService)]
    ),
    email: new FormControl('', [
      Validators.required,
      Validators.email,
    ]),
  });

  get username() {
    return this.formGroup.get('username');
  }

  get email() {
    return this.formGroup.get('email');
  }

  // Injeta a UserService para ser utilizada no
  // validador assíncrono do username
  constructor(private userService: UserService) {}

  postar() {
    if (this.formGroup.invalid) {
      return;
    }

    console.log(this.formGroup.value);
  }
}
app.component.ts

4-Template do AppComponent

No template também tiveram duas alterações:

  1. Inclusão da mensagem de erro quando o username não está disponível para uso
  2. Inclusão do formGroup.pending para desabilitar o botão Cadastrar caso o FormGroup ainda esteja processando alguma validação, que no nosso caso será a validação assíncrona do username:
 <!-- ...(trecho ocultado)... -->

 <!-- VALIDAÇÃO ASSÍNCRONA -->
<div *ngIf="username.errors['usernamejaexiste']">
  <strong>{{
    username.errors["usernamejaexiste"]
  }}</strong>
  não está disponível
</div>

<!-- ...(trecho ocultado)... -->

<div class="col-12">
  <button
    [disabled]="formGroup.invalid || formGroup.pending"
    label="Cadastrar"
    pButton
    type="submit"></button>
</div>

<!-- ...(trecho ocultado)... -->
app.component.html
After asynchronous validation begins, the form control enters a pending state. Inspect the control's pendingproperty and use it to give visual feedback about the ongoing validation operation.

fonte: https://angular.io/guide/form-validation#creating-asynchronous-validators

Problema com CORS: no meu ambiente de testes o frontend está rodando na porta 4200 e a API de validação na porta 3000. Neste caso vamos ter problemas de Cross-Origin Resouce Sharing (CORS), então para efetuar um bypass no seu navegador utilize uma das opções abaixo, lembrando que isto deve ser feito somente em ambiente de desenvolvimento:

  • Chrome Windows: execute-o utilizando a seguinte opção:
    chrome.exe" --disable-web-security --disable-gpu --user-data-dir=~/chromeTemp.
  • Chrome macOS: open -n -a /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --args --user-data-dir="/tmp/chrome_dev_test" --disable-web-security
  • Safari: vá até a aba Developer e selecione a opção Disable Cross-Origin Restrictions

Executando o projeto temos o seguinte resultado:

Testando as validações síncronas e assíncronas do formulário no navegador
Formulário com validações síncronas e assíncronas

No GIF acima é possível ver que o formulário está funcionando corretamente. Conforme o usuário preenche o campo username, o navegador efetua uma requisição para validar a disponibilidade na API.

O grande problema deste modelo é a cada tecla pressionada no campo username é disparada uma nova requisição. O ideal seria que essa requisição fosse disparada somente quando o usuário passar um determinado tempo sem digitar, por exemplo, disparar a requisição quando o intervalo entre as teclas pressionadas for maior que 500 milissegundos. Por sorte o RxJS oferece um operador chamado debounceTime que faz este trabalho.

Aplicando o debounceTime no AsyncValidator

Abaixo há uma modificação no método usernameJaExiste para incluir o debounceTime. Deixei comentários ao longo do código para facilitar o entendimento:

import {
  AbstractControl,
  AsyncValidatorFn,
  ValidationErrors,
} from '@angular/forms';
import {
  BehaviorSubject,
  debounceTime,
  map,
  Observable,
  switchMap,
  take,
} from 'rxjs';
import { UserService } from './user.service';

export class AppValidator {
  static usernameJaExiste(
    userService: UserService
  ): AsyncValidatorFn {
    const subject = new BehaviorSubject<string>('');

    const output = subject.asObservable().pipe(
      // Só emite um valor quando o intervalo
      // entre as teclas pressionadas for maior
      // que 500ms
      debounceTime(500),
      // Retorna um observable que emite apenas
      // o primeiro valor e então aplica o
      // "completed" do Observable
      take(1),
      // "Troca" o valor emitido, que é o que o
      // usuário digitou, por um novo observable
      // resultante da consulta à API
      switchMap((username: string) =>
        userService.get(username).pipe(
          // Como a API não retorna o "username",
          // utilizamos o "map" para modificar a
          // resposta obtida na consulta à API
          map((apiResponse) => ({
            ...apiResponse,
            username,
          }))
        )
      ),
      map((response) => {
        return response.disponivel
          ? // Se o usuário estiver disponível:
            null
          : // Caso o usuário não esteja disponível,
            // emitimos o erro:
            ({
              usernamejaexiste: response.username,
            } as ValidationErrors);
      })
    );

    return (
      control: AbstractControl
    ): Observable<ValidationErrors | null> => {
      const username = control.value;

      // Sempre que o usuário digitar algo,
      // passamos o valor para o "subject"
      subject.next(username);
      return output;
    };
  }
}
app.validator.ts
Testando a validação assíncrona como debounceTime no navegador
Testando a validação assíncrona como debounceTime

Após o ajuste no validador assíncrono, é possível perceber que as requisições à API de validação foram reduzidas e são feitas somente quando o intervalo entre a penúltima e a última tecla pressionada no campo username for maior que 500ms.

Agora temos um formulário completo, com validações síncronas e assíncronas!

Considerações

Eventualmente sua fonte de validação será assíncrona, como vimos no texto acima, consequentemente o AsyncValidatorFn será um ótimo recurso para se usar.

Um outro ponto relevante que podemos destacar, é o aumento da complexidade do código quando aplicamos o debounceTime. Por outro lado, esta alteração entregou um belo ganho em performance ao reduzir a quantidade de chamadas ao backend.

Links interessantes: