Reactive Forms

<< Template-driven Forms Proyección de contenido >>

Mientras que los formularios de plantilla son más sencillos de implementar a priori y requieren menos código (en la clase del componente), los formularios reactivos son más flexibles, dinámicos y más fáciles de probar (pruebas unitarias). Además, el código HTML de la plantilla queda más limpia. Un formulario se representa internamente mediante un objeto FormGroup, mientras que los campos se representan como un objeto FormControl.

Para empezar a usar formularios reactivos, en lugar de importar FormsModule, necesitaremos importar ReactiveFormsModule:

@Component({
  //...
  imports: [/*...*/, ReactiveFormsModule],
  //...
})
export class ProductFormComponent implements CanComponentDeactivate {
  //...
}

Crear un formulario reactivo

Lo primero que debemos hacer es crear un objeto FormGroup que representará todo el formulario. Podemos crear más FormGroups dentro de ese objeto que representen secciones (grupos de campos) de ese formulario. Cada elemento de entrada que necesitemos integrar (validar, obtener su valor, etc.) debe estar representado por un objeto FormControl.

export class ProductFormComponent implements CanComponentDeactivate {
  productForm = new FormGroup({
    description: new FormControl(''),
    price: new FormControl(0),
    available: new FormControl(''),
    image: new FormControl('')
  });
  
  imageBase64 = '';

  //...
}

No necesitaríamos tener un objeto que represente el producto a enviar en este caso (newProduct), ya que los valores los obtendremos del formulario a la hora de enviarlo. Lo único necesario será un string externo al formulario (imageBase64) para almacenar la imagen en formato base64, ya que el control del formulario que representa la imagen está vinculado al input de tipo archivo.

Como se puede observar, el constructor de FormControl puede recibir el valor por defecto. Si no establecemos ninguno, este valor sería null. Por ello, todos los campos están tipados con el tipo del valor establecido. Si no establecemos un valor por defecto lo podríamos tipar de la siguiente manera: new FormControl<number>(), por ejemplo.

Los valores de los objetos FormControl se tipan también con null. Es decir, si el campo almacena un string, el tipo será string | null. Esto es así porque cuando se resetea el formulario, por defecto, todos los valores se asignan a null. Si queremos que esto no sea así, y que se asigne el valor inicial al resetear, debemos añadir la opción nonNullable.

export class ProductFormComponent implements CanComponentDeactivate {
  productForm = new FormGroup({
    description: new FormControl('', { nonNullable: true }),
    price: new FormControl(0, { nonNullable: true }),
    available: new FormControl('', { nonNullable: true }),
    image: new FormControl('', { nonNullable: true }),
  });
  //...
}

FormBuilder

El servicio FormBuilder permite construir formularios reactivos con una sintáxis más simple. A cada propiedad solamente se le asigna el valor, y automáticamente se construye un objeto de tipo FormControl con dicho valor. Si queremos que el formulario no tenga valores nulos, usaremos el servicio NonNullableFormBuilder en su lugar.

export class ProductFormComponent implements CanComponentDeactivate {
  //...
  #fb = inject(NonNullableFormBuilder);

  productForm = this.#fb.group({
    description: '',
    price: 0,
    available: '',
    imageUrl: '',
  });
  //...
}

En los siguientes ejemplos utilizaremos este servicio para la construcción de formularios reactivos.

Vincular formulario en la plantilla

Para vincular el formulario en la plantilla usaremos la directiva formGroup en el formulario vinculada al objeto FormGroup en el componente. Los campos se vinculan con la directiva formControlName. No se ponen los validadores de los campos en la plantilla (veremos más adelante como ponerlos). El atributo name puede omitirse al contrario que con los formularios de plantilla.

<form (ngSubmit)="addProduct()" [formGroup]="productForm">
  <label class="mb-3 row">
    <span class="col-sm-2 col-form-label text-sm-end">Description</span>
    <div class="col-sm-10">
      <input
        type="text"
        class="form-control"
        formControlName="description"
      />
      <!-- Mensajes de error -->
    </div>
  </label>
  <!-- Resto del formulario  -->
</form>

Existe también la directiva formControl. La diferencia con formControlName, es que esta última recibe un string con el nombre de la propiedad del objeto FormGroup que representa el valor del campo, mientras que formControl requiere una referencia al objeto FormControl directamente.

Si estamos adaptando el código anterior con formularios de plantilla, debemos tener en cuenta que los validadores en la plantilla no funcionarán y debemos quitarlos. El código como el de enviar el formulario, por ahora lo dejaremos comentado.

Modificar valores desde código

Mientras que en los formularios de plantilla, cambiando el valor de la propiedad asociada con ngModel a un campo, cambia el valor del campo, en el caso de los formularios reactivos es un poco diferente la metodología.

setExampleValues() {
  this.productForm.patchValue({
    description: 'Ejemplo',
    price: 100,
    available: '2022-01-01'
  });
}

También se puede modificar el valor de un campo accediendo a él (objeto FormGroup) y llamando al método setValue.

setExampleValues() {
  this.productForm.get('description')?.setValue('Ejemplo');
  //...
}

Resetear formulario

Resetear un formulario implica volver a los valores iniciales de los campos y además reiniciar los campos a pristine y untouched para eliminar los estilos de validación. Se le puede pasar un objeto con los valores de los campos que queremos establecer. Si no pasamos ningún valor, el campo se reestablece al valor inicial.

export class ProductFormComponent implements CanComponentDeactivate {
  //...
  resetForm() {
    this.productForm.reset();
  }
  //...
}

Envío del formulario

EL envío del formulario se gestiona exactamente igual que para los formularios de plantilla, mediante el evento ngSubmit. Los valores los tendremos que obtener del formulario (FormGroup) y construir el objeto tipo Product que enviaremos al servidor.

El método getRawValue nos devuelve un objeto con los campos del fomrulario (propiedades) y su valor. Tenemos que añadirle la puntuación (rating) y sustituir el valor de la propiedad imagen (imageUrl) por la imagen en base64 que hemos generado.

export class ProductFormComponent implements CanComponentDeactivate {
  //...
  addProduct() {
    const product: Product = {
      ...this.productForm.getRawValue(),
      rating: 1,
      imageUrl: this.imageBase64,
    };
    this.#productsService.insertProduct(product).subscribe(() => {
      this.saved = true;
      this.#router.navigate(['/products']);
    });
  }
  //...
}

Validar formulario

Angular tiene validadores equivalentes a los que se utilizan en HTML para los formularios reactivos. Los validadores son métodos estáticos de la clase Validators (@angular/forms).

Para asignar un validador a un campo del formulario, en nuestro caso que estamos usando FormBuilder, declaramos el campo como un array de 2 valores. El primer valor será el valor inicial del campo, y el segundo un array con los validadores a aplicar. Si solo hay un validador, no haría falta especificar un array.

export class ProductFormComponent implements CanComponentDeactivate {
  //...
  productForm = this.#fb.group({
    description: ['', [Validators.required, Validators.minLength(5)]],
    price: [0, [Validators.required, Validators.min(0.1)]],
    available: ['', [Validators.required]],
    imageUrl: ['', [Validators.required]],
  });
  //...
}

La directiva validationClasses la creamos en la sección anterior de Formularios de Plantilla, y contemplaba que el input tuviera el atributo y por tanto la directiva ngModel. Vamos a hacerla un poco más flexible, para poder utilizarla también con formularios reactivos. 

@Directive({
  selector: '[validationClasses][ngModel],[validationClasses][formControl],[validationClasses][formControlName])',
  standalone: true,
  host: {
    '[class]': 'inputClass()',
    '(blur)': 'touched.set(true)',
  },
})
export class ValidationClassesDirective implements OnInit {
  #ngModel = inject(NgModel, { optional: true }); // Formulario de plantilla
  #ngControl = inject(NgControl, { optional: true }); // Formulario reactivo
  #injector = inject(Injector);
  validationClasses = input<{ valid: string; invalid: string }>();
  valueChanges!: Signal<string>;
  touched = signal(false);

  ngOnInit(): void {
    // La directiva NgControl no está lista hasta este momento del ciclo de vida
    this.valueChanges = toSignal(
      this.#ngModel?.valueChanges ?? this.#ngControl?.valueChanges ?? EMPTY,
      { injector: this.#injector }
    );
  }

  inputClass = computed(() => {
    const touched = this.touched(); // dependencia
    const validationClasses = this.validationClasses(); // dependencia
    this.valueChanges(); // dependencia

    return untracked(() => {
      if (touched) {
        return this.#ngModel?.invalid || this.#ngControl?.invalid
          ? validationClasses?.invalid
          : validationClasses?.valid;
      }
      return '';
    });
  });
}

Ya podemos añadirla a la plantilla del formulario

<!-- ... -->
<input
  type="text"
  class="form-control"
  formControlName="description"
  [validationClasses]="{valid: 'is-valid', invalid: 'is-invalid'}"
/>
<!-- ... -->

Mostrar mensajes de validación

Para acceder a las propiedades de validación de los campos el fomulario se puede acceder a partir del objeto del formulario, con la colección controls, a los objetos FormControl internos. Las propiedades para la validación son las mismas que usando NgModel con los formularios de plantilla. Vamos a ver un ejemplo con el campo de la descripción:

<!-- ... -->
<input
  type="text"
  class="form-control"
  formControlName="description"
  [validationClasses]="{valid: 'is-valid', invalid: 'is-invalid'}"
/>
@let descErrors = productForm.get('description')?.errors;
@if(descErrors?.['required']) {
  <div class="invalid-feedback">La descripción es obligatoria</div>
}
@if(descErrors?.['minlength']) {
  <div class="invalid-feedback">
    Te faltan al menos
    {{descErrors?.['minlength'].requiredLength - descErrors?.['minlength'].actualLength}}
    caracteres más
  </div>
}
<!-- ... -->

Crear validadores personalizados

Los validadores en formularios reactivos son funciones (del tipo ValidatorFn) en lugar de directivas. Estas funciones las podemos crear en el propio archivo del componente (puede ser un método del mismo), o en un archivo aparte si queremos poder reutilizarlas fácilmente. Esta función debe devolver una función de validación, equivalente al método validate en las directivas para los formularios de plantilla. Esta función recibe el objeto que representa el campo del formulario, y devuelve un objeto con el error o null si no hay error.

import { ValidatorFn, AbstractControl, ValidationErrors } from '@angular/forms';

export function minDateValidator(minDate: string): ValidatorFn {
  return (c: AbstractControl): ValidationErrors | null => {
    if (c.value && minDate && minDate > c.value) {
      return { minDate: true };
    }
    return null;
  };
}

Validadores de grupo (FormArray)

Para crear un validador y aplicarlo a un conjunto de campos, primero debemos agruparlos. Para esto tenemos los objetos FormGroup y FormArray.

En el caso del ejemplo que vimos con un formulario de plantilla, donde validabamos que al menos estuviera marcado un día de la semana, como son varios input del mismo tipo seguidos (que representan los días de la semana), la mejor solución es utilizar un objeto FormArray. Creamos un objeto de este tipo con el método array del objeto FormBuilder.

Cuando el validador no recibe parámetros, no hace falta crear una función del tipo ValidatorFn que devuelva otra función de validación. Basta con crear directamente la función de validación (que recibe el campo del formulario por parámetro), y en el array de validadores poner el nombre de la función sin llamarla.

<!-- ... -->
<div class="mb-3" formArrayName="daysOpen">
  @for(day of myForm.controls.daysOpen.controls; track day; let i = $index) {
    <div class="form-check form-check-inline">
      <!-- Inputs must have DIFFERENT names -->
      <input type="checkbox" class="form-check-input" [formControlName]="i" id="day{{i}}">
      <label class="custom-control-label ms-2" for="day{{i}}">{{days[i]}}</label>
    </div>
  }
  @if(productForm.controls.daysOpen.invalid) {
    <div class="text-danger">
      Debes seleccionar al menos un día
    </div>
  }
</div>
<!-- ... -->

Validadores de grupo (FormGroup)

Si queremos crear una estructura agrupada con campos independientes (su propio nombre de campo, tipos diferentes, etc.) la mejor opción sería un objeto FormGroup. Esto sería equivalente a usar la directiva ngModelGroup en los formularios de plantilla.

<form [formGroup]="userForm">
  <p><input type="text" placeholder="Name" formControlName="name"></p>
  <div formGroupName="emailGroup">
    <p><input type="email" placeholder="Email" formControlName="email"></p>
    <p><input type="email" placeholder="Repeat Email" formControlName="emailConfirm"></p>
  </div>
  <!-- ... -->
</form>

Al crear subgrupos en el formulario, a la hora de acceder a los valores del formulario de arriba, nos devolvería una estructura como esta:

{
  name: 'Nombre',
  emailGroup: {
    email: 'email1@email.com',
    emailConfirm: 'email1@email.com',
  }
}

Si no queremos crear estas estructuras de grupos dentro del formulario, siempre podríamos ponerle el validador de grupo directamente al objeto (FormGroup) del formulario. Otra opción sería crear un validador simple para el segundo campo y pasarle la referencia al primer campo por parámetro.

Mejorar el canDeactivate del formulario

A lo largo del curso, hemos configurado un guard de tipo CanDeactivate para que pregunte cuando queremos abandonar la página del formulario. Al guardar el producto y redirigir a otra página utilizamos un booleano para controlar que no pregunte. Sin embargo, sería buena idea que no preguntase tampoco si el formulario no ha sido modificado.

Como en los formularios reactivos tenemos acceso al objeto FormGroup directamente en el componente, podemos consultar su estado, y entre otras cosas la propiedad pristine (no modificado), o su opuesta, dirty, que nos indica si el formulario (alguno de sus campos) ha sido modificado. Veremos más adelante como hacer lo mismo con formularios de plantilla utilizando View Queries.

export class ProductFormComponent implements CanComponentDeactivate {
  //...
  canDeactivate() {
    return (
      this.saved || this.productForm.pristine ||
      confirm('¿Quieres abandonar la página?. Los cambios se perderán...')
    );
  }
}

Reaccionando a cambios en el valor

Los objetos del tipo FormControl, FormGroup y FormArray tienen una propiedad de tipo Observable llamada valueChanges. Si nos suscribimos a este observable podemos recibir el valor cada vez que cambie y ejecutar una función asociada.

export class ProductFormComponent implements CanComponentDeactivate {
  //...
  constructor() {
    //...
    this.productForm.get('price').valueChanges
        .subscribe(price => /* Acción cada vez que cambia el precio */);
  }
}

Busqueda con retardo (debounce)

Utilizando las posibilidades de los observables, podemos implementar un campo de búsqueda con formularios reactivos. En este caso no sería un formulario, sino simplemente un objeto del tipo FormControl. Podemos reaccionar a los cambios de valor (valueChanges) y procesarlos utilizando las siguientes funciones de rxjs:

  • debounceTime - Tiempo mínimo para dejar pasar el siguiente valor. Si se emite un nuevo valor antes de ese tiempo, el anterior es descartado y el contador reiniciado) ignorando los anteriores.
  • distinctUntilChanged - Si el valor es igual que el último emitido, no lo deja pasar. Solo si ha cambiado.
  • swtichMap - Devuelve un observable (la llamada al servidor para filtrar) y el observable actual intercambia su valor (texto de búsqueda) por el valor que emite el observable devuelto.
<!-- ... -->
<input type="text" [formControl]="searchControl" placeholder="Buscar">
<!-- ... -->

Con Angular 19 podemos utilizar la API rxResource para hacer nuestro código más reactivo.

export class ProductsComponent {
  //...
  searchControl = new FormControl('');
  search = toSignal(
    this.searchControl.valueChanges.pipe(
        debounceTime(600), // 600 milisegundos hasta que deja de escribir
        distinctUntilChanged(), // Solo si el valor cambia
    )
  )

  productsResource = rxResource<Product | undefined, string>({
    request: () => this.search(), // Dependencia
    loader: ({ request: search }) => this.#productsService.getProducts(search) // Cada vez que cambia search se recargan los productos
  });

  products = computed(() => this.productsResource.value() || []);
  // ...
  addProduct(product: Product) {
    this.productsResource.update((products) => [...(products || []), product]);
  }

  deleteProduct(product: Product) {
    this.productsResource.update((products) => products?.filter((p) => p !== product));
  }
}

<< Template-driven Forms Proyección de contenido >>