Formularios en Angular#

Los formularios son una parte fundamental de cualquier aplicación web, y Angular ofrece tres enfoques principales para manejarlos: formularios reactivos, formularios basados en plantillas y signal Forms.

Característica

Signal Forms (Nuevo/v21+)

Formularios Reactivos

Basados en Plantillas

Origen de los datos

Un modelo de Signals.

Clases de Angular (FormControl, FormGroup).

El propio modelo/objeto en el HTML.

Seguridad de tipos

Máxima: Se deduce automáticamente de tus Signals.

Alta: Requiere definir tipos en los formularios.

Mínima: Es propenso a errores de escritura.

Validación

Basada en Esquemas (funciones que siguen rutas).

Lista de funciones que pasas a cada Control.

Directivas puestas directamente en el HTML.

Gestión del estado

Basada en Signals (reactividad pura).

Basada en Observables (RxJS).

Gestionada internamente por Angular.

Configuración

Signal + Función de esquema.

Árbol de FormControls en el código.

ngModel en el HTML.

Ideal para…

Apps modernas que buscan rendimiento extremo.

Formularios complejos y muy dinámicos.

Formularios muy simples o prototipos rápidos.

Curva de aprendizaje

Media

Media - Alta (por RxJS)

Baja

Con Angular 21 no se considera ninguno obsoleto pero en proyectos nuevos se deberia usar solo los basados en Signals para poder crear aplicaciones Zoneless. Pero la industria es muy diversa y es necesario conocerlos todos, ya que la mayoría de aplicaciones en producción están sobre versiones anteriores al 21.

Formularios de Plantilla en Angular#

Usaremos los formularios de plantilla para aquellos que sean muy simples y no requieran una validación o manejo de los datos sofisticados.

Configuración de Formularios Basados en Plantillas#

Para empezar a trabajar con formularios basados en plantillas, necesitamos importar el módulo FormsModule en nuestro componente:

import { FormsModule } from '@angular/forms';

...
  imports: [
    FormsModule,
    // otros módulos
  ],
...

Uso de ngModel y Validadores HTML5#

Los formularios basados en plantillas en Angular permiten utilizar validadores HTML5 directamente en los elementos input. Estos validadores aplican automáticamente clases de estilo dependiendo del estado del formulario.

<input
  type="text"
  class="form-control"
  [(ngModel)]="product.description"
  minlength="5"
  maxlength="600"
  required
/>

Modificar Estilos al Validar#

Podemos cambiar los estilos de los elementos del formulario en función de su estado de validación utilizando clases CSS personalizadas y la referencia a ngModel.

<input
  type="text"
  name="description"
  class="form-control"
  [(ngModel)]="product.description"
  minlength="5"
  maxlength="600"
  required
  #descriptionModel="ngModel"
  [ngClass]="{
    'is-valid': descriptionModel.touched && descriptionModel.valid,
    'is-invalid': descriptionModel.touched && !descriptionModel.valid
  }"
/>
<div>
  <div>{{ product | json }}</div>
  <div>Dirty: {{ descriptionModel.dirty }}</div>
  <div>Valid: {{ descriptionModel.valid }}</div>
  <div>Value: {{ descriptionModel.value }}</div>
  <div>Errors: {{ descriptionModel.errors | json }}</div>
</div>

Manipular la Entrada del Usuario en Tiempo Real#

Podemos separar la vinculación del modelo y el evento ngModelChange para manipular la entrada del usuario en tiempo real.

<input
  type="text"
  class="form-control"
  [ngModel]="product.description"
  (ngModelChange)="product.description = $event.toUpperCase()"
/>

ngForm y Directiva noValidate#

Por defecto, todos los formularios en Angular tienen la directiva ngForm. Podemos hacer una referencia a esta directiva para observar las propiedades generales del formulario. Es recomendable usar la directiva novalidate para desactivar la validación del navegador y permitir que Angular gestione la validación.

<form #productForm="ngForm" novalidate>
  <input type="text" name="description" ... />
</form>
<div>
  <div>Touched: {{ productForm.touched }}</div>
  <div>Valid: {{ productForm.valid }}</div>
  <div>Value: {{ productForm.value | json }}</div>
  <div>Descripción: {{productForm.control.get('description').value | json}}</div>
</div>

Mostrar los Errores de Validación#

Podemos combinar estilos de Bootstrap, validación HTML5, ngIf y referencias a ngModel para mostrar mensajes de error de validación.

<form #productForm="ngForm" novalidate>
  <input
    type="text"
    name="description"
    class="form-control"
    [(ngModel)]="product.description"
    minlength="5"
    maxlength="600"
    required
    #descriptionModel="ngModel"
    [ngClass]="{
      'is-valid': descriptionModel.touched && descriptionModel.valid,
      'is-invalid': descriptionModel.touched && !descriptionModel.valid
    }"
  />
  <div *ngIf="descriptionModel.touched && descriptionModel.invalid" class="alert alert-danger">
    Descripción requerida (entre 5 y 60 caracteres)
  </div>
</form>

Creación de Validadores Personalizados#

Angular permite la creación de validadores personalizados mediante directivas. Estos validadores se registran como validadores de Angular utilizando el proveedor NG_VALIDATORS.

import { Directive, Input } from '@angular/core';
import { Validator, AbstractControl, NG_VALIDATORS } from '@angular/forms';

@Directive({
  selector: '[appMinPrice]',
  providers: [{ provide: NG_VALIDATORS, useExisting: MinPriceDirective, multi: true }]
})
export class MinPriceDirective implements Validator {
  @Input('appMinPrice') minPrice: number;

  constructor() { }

  validate(c: AbstractControl): { [key: string]: any } | null {
    if (this.minPrice && c.value) {
      if (this.minPrice > c.value) {
        return { minPrice: true };
      }
    }
    return null;
  }
}

Enviar el Formulario#

Podemos enviar el formulario y realizar validaciones adicionales antes de enviarlo. Además, podemos desactivar el botón de envío hasta que el formulario sea válido.

<form #productForm="ngForm" (ngSubmit)="editar(productForm)" novalidate>
  <input type="text" name="description" ... />
  <button type="submit" class="btn btn-primary" [disabled]="productForm.invalid">Submit</button>
</form>

En el componente, obtenemos el formulario utilizando @ViewChild y la variable de referencia:

@ViewChild('editForm', { static: true }) editForm: NgForm;

editar(productForm: NgForm) {
  if (this.editForm.valid) {
    this.productService.editProduct(this.product).subscribe(
      ok => this.router.navigate(['/product/', this.product.id])
    );
  }
}

Formularios Reactivos#

En Angular, los formularios reactivos representan un conjunto de técnicas que permiten controlar mejor el comportamiento de los formularios desde el código del componente en lugar de desde la plantilla. Aunque el uso de [(ngModel)] en formularios basados en plantillas ya proporciona cierto comportamiento reactivo, los formularios reactivos ofrecen una manera más estructurada y potente de manejar formularios complejos. Es recomendable usar formularios reactivos, especialmente en formularios más avanzados que van más allá de simples búsquedas o inicios de sesión.

Para trabajar con formularios reactivos, debemos importar el módulo ReactiveFormsModule

import { ReactiveFormsModule } from '@angular/forms';

...
  imports: [
    ReactiveFormsModule,
    // otros módulos
  ],
...

En los formularios reactivos, declaramos directamente en el código los objetos FormControl, FormGroup y FormArray. A continuación, se muestra un ejemplo básico de cómo configurar un formulario reactivo:

  1. Definir el Formulario en el Componente

En el componente, necesitamos una variable de tipo FormGroup inicializada en el constructor. Utilizaremos un servicio inyectado llamado FormBuilder para construir el formulario.

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

@Component({
  selector: 'app-product-form',
  templateUrl: './product-form.component.html'
})
export class ProductFormComponent {
  formulario: FormGroup;

  constructor(private formBuilder: FormBuilder) {
    this.crearFormulario();
  }

  crearFormulario() {
    this.formulario = this.formBuilder.group({
      name: [''],
      price: [0],
      description: [''],
    });
  }

  crear() {
    // Lógica para crear el producto
  }
}
  1. HTML del Formulario

En el HTML del formulario, solo necesitamos agregar [formGroup] y formControlName en cada input.

<form [formGroup]="formulario" (ngSubmit)="crear()">
  <div class="form-group">
    <label for="inputName">Name</label>
    <input type="text" class="form-control" id="inputName" formControlName="name">
  </div>
  <div class="form-group">
    <label for="inputPrice">Price</label>
    <input type="number" class="form-control" id="inputPrice" formControlName="price">
  </div>
  <div class="form-group">
    <label for="inputDescription">Description</label>
    <input type="text" class="form-control" id="inputDescription" formControlName="description">
  </div>
  <button type="submit" class="btn btn-primary">Submit</button>
</form>

Validaciones Síncronas#

En los formularios reactivos, las validaciones son más sencillas y se pueden declarar directamente en el código del componente usando Validators.

import { Validators } from '@angular/forms';

crearFormulario() {
  this.formulario = this.formBuilder.group({
    name: ['', [Validators.required, Validators.minLength(5), Validators.pattern('.*[a-zA-Z].*')]],
    price: [0, Validators.min(0.01)],
    description: [''],
  });
}

get nameNotValid() {
  return this.formulario.get('name').invalid && this.formulario.get('name').touched;
}

Para aplicar las validaciones visualmente en el HTML, podemos utilizar getters:

<input type="text" class="form-control" id="inputName" formControlName="name" 
  [ngClass]="nameNotValid ? 'is-invalid' : 'is-valid'">

Creación de Validadores Personalizados#

Los validadores personalizados en formularios reactivos son funciones que devuelven una ValidatorFn. Aquí mostramos cómo crear un validador personalizado para fechas mínimas:

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

function minDateValidator(minInputDate: string): ValidatorFn {
  return (c: AbstractControl): { [key: string]: any } | null => {
    if (c.value) {
      const minDate = new Date(minInputDate);
      const inputDate = new Date(c.value);
      return minDate <= inputDate ? null : { 'minDate': minDate.toLocaleDateString() };
    }
    return null;
  };
}

Validación de Campos Cruzados#

Podemos crear validadores personalizados para evaluar un campo en función de otros campos en el formulario. Aquí un ejemplo para validar que dos campos de contraseña coincidan:

this.registerForm = this.formBuilder.group({
  password: ['', [Validators.required, Validators.minLength(3), Validators.maxLength(10)]],
  confirm_password: ['', [Validators.required, Validators.minLength(3), Validators.maxLength(10)]]
}, {
  validators: passwordValidator
});

const passwordValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
  const password = control.get('password');
  const confirmPassword = control.get('confirm_password');
  return password && confirmPassword && password.value === confirmPassword.value ? null : { passwordValidator: true };
};

Agrupar Campos#

Podemos agrupar campos en un FormGroup dentro de otro FormGroup:

this.formulario = this.formBuilder.group({
  name: ['', [Validators.required, Validators.minLength(5), Validators.pattern('.*[a-zA-Z].*')]],
  price: [0, Validators.min(0.01)],
  description: [''],
  address: this.formBuilder.group({
    street: [''],
    city: ['']
  })
});

Y en el HTML:

<div class="form-group row mb-3" formGroupName="address">
  <label for="inputStreet" class="col-sm-2 col-form-label">Street</label>
  <div class="form-row col">
    <div class="col">
      <input class="form-control" id="inputStreet" placeholder="Street" formControlName="street">
    </div>
    <div class="col">
      <input class="form-control" id="inputCity" placeholder="City" formControlName="city">
    </div>
  </div>
</div>

Cargar Datos en el Formulario#

Si estamos trabajando en un formulario de edición, podemos cargar los valores en los controles de formulario utilizando setValue(), patchValue() o reset().

this.formulario.setValue(this.product);  // Necesita todos los campos

// Para resetear con valores por defecto
this.formulario.reset({
  name: 'Default Name',
  price: 0,
  description: 'Default Description'
});

// Para actualizar solo algunos campos
this.formulario.patchValue({
  name: 'Updated Name'
});

Detección de Cambios#

Los FormGroup y FormControl contienen un Observable llamado valueChanges que emite el valor actual del control cada vez que cambia. Podemos suscribirnos a este Observable para realizar acciones en respuesta a los cambios.

export class AppComponent implements OnInit {
  formulario: FormGroup;

  constructor(private formBuilder: FormBuilder) { }

  ngOnInit() {
    this.formulario = this.formBuilder.group({
      notifications: [false]
    });

    this.formulario.get('notifications').valueChanges
      .subscribe(value => this.updateNotificationMethod(value));
  }

  updateNotificationMethod(value: boolean) {
    // Lógica para manejar cambios en las notificaciones
  }
}

Formularios Dinámicos#

Los FormArray permiten crear formularios dinámicos donde los controles pueden añadirse o eliminarse en tiempo de ejecución.

Para funcionar necesitamos crear un formArray dentro del formGroup del formulario. En el ejemplo lo hacemos con formBuilder.array. Para generar nuevos formControl dentro de ese array, en este caso hemos creado dos funciones getIngredientControl y generateIngredientControl que crean un control de forma menos concisa que con los formBuilder.

Hemos hecho funciones para crear nuevos controles y borrarlos, así como la función que rellena el formulario y crea tantos controles como elementos tenga el array. Puesto que un formArray es un tipo especial de formControl, hay que castearlo cuando se obtiene por get y tiene funciones útiles como push o removeAt. Observemos cómo las usamos en el ejemplo.

  @Input('id') recipeID?: string;
  mealForm: FormGroup;

  constructor(
    private supaService: SupabaseService,
    private formBuilder: FormBuilder
  ) {
    this.mealForm = this.formBuilder.group({
      strMeal: ['', [Validators.required]],
      strInstructions: ['', [Validators.required]],
      ingredients: this.formBuilder.array([]),
    });
  }

 ngOnInit(): void {
    if (this.recipeID) {
      // suponiendo que tenemos supabase como backend
      this.supaService.getMeals(this.recipeID).subscribe({
        next: (meals) => {
          this.mealForm.reset(meals[0]);
          meals[0].idIngredients.forEach(i=>{  // idIngredients es un array de ids
            if(i){
              (<FormArray>this.mealForm.get('ingredients')).push(
                this.generateIngredientControl(i)
             )
            }
      
          })
     
        },
        error: (err) => console.log(err),
        complete: () => console.log('Received'),
      });
    }
  }

  getIngredientControl(): FormControl {
    const control = this.formBuilder.control('');
    control.setValidators(Validators.required);
    return control;
  }

  generateIngredientControl(id: string): FormControl {
    const control = this.formBuilder.control(id);
    control.setValidators(Validators.required);
    return control;
  }

  get IngredientsArray(): FormArray {
    return <FormArray>this.mealForm.get('ingredients');
  }

  addIngredient() {
    (<FormArray>this.mealForm.get('ingredients')).push(
      this.getIngredientControl()
    );
  }
  delIngredient(i: number) {   
    (<FormArray>this.mealForm.get('ingredients')).removeAt(i);
  }

Y en el HTML hay que crear un contenedor con el atributo formArrayName en el que podemos hacer un @for para recorrer el formArray obtenido por el getter creado anteriormente. Este tiene un interable .controls que contiene la información del control. El input debe tener formControName al índice del @for.

En el ejemplo tenemos botones para añadir o borrar elementos del formArray.

    <div formArrayName="ingredients">
      @for (ingredient of IngredientsArray.controls; track $index) {
      <div class="input-group mb-3">
        <input
          type="text"
          class="form-control"
          id="ingredient{{ $index }}"
          name="ingredient{{ $index }}"
          [formControlName]="$index"
          placeholder="ingredient"
          [ngClass]="{
            'is-valid': ingredient.valid && ingredient.touched,
            'is-invalid': ingredient.invalid && ingredient.touched
          }"
        />
        <button class="btn btn-outline-danger" (click)="delIngredient($index)">
          Borrar
        </button>
      </div>
      <button class="btn btn-primary" (click)="addIngredient()">
        Añadir ingrediente
      </button>

Signal Forms#

  1. Reactividad Atómica: A diferencia de los Formularios Reactivos, donde tienes que suscribirte a valueChanges (que es un Observable), en los Signal Forms accedes al valor simplemente llamando a la Signal. Esto elimina la necesidad de gestionar suscripciones y el riesgo de fugas de memoria (memory leaks).

  2. Validación con computed: En lugar de validadores complejos que se disparan en cada ciclo de detección, las validaciones pueden ser Signals derivadas (computed). Esto significa que la validación solo se vuelve a calcular si el valor del campo específico cambia.

  3. Adiós a Zone.js: Es la mayor ventaja competitiva. Mientras que los formularios tradicionales fuerzan a Angular a revisar todo el componente para ver si algo cambió, los Signal Forms informan exactamente qué campo debe actualizarse en el DOM.

  4. Tipado fuerte (Type Safety): Los Signal Forms están diseñados desde cero para ser totalmente compatibles con TypeScript, evitando los problemas de tipos any o unknown que históricamente plagaron a los formularios de Angular.

Nota de contexto: En las versiones más recientes de Angular, se está trabajando en una API oficial para integrar Signals profundamente en los formularios. Actualmente, muchos desarrolladores ya están usando patrones de “Signal-like state” o librerías de la comunidad que siguen este estándar para adelantarse al futuro del framework.

Crear un formulario con signal()#

Todo formulario empieza con un modelo de datos:

interface LoginData {
  email: string;
  password: string;
}
const loginModel = signal<LoginData>({
  email: '',
  password: '',
});

Estos datos pueden ser inicializados en este momento o actualizado después.

Se usa la función form() para crear un FieldTree. Esto es una estructura de datos adecuada para acceder a los datos:

const loginForm = form(loginModel);
// Access fields directly by property name
loginForm.email;
loginForm.password;

En el formulario HTML se vincula mediante [field]:

<input type="email" [field]="loginForm.email" />
<input type="password" [field]="loginForm.password" />

Ahora se pueden leer los datos del formulario llamando como función a los fields. Esto retorna un FieldState que tiene información sobre el estado no solo del value, sino del input:

loginForm.email(); // Returns FieldState with value(), valid(), touched(), etc.
// Get the current value
const currentEmail = loginForm.email().value();
<!-- Render form value that updates automatically as user types -->
<p>Email: {{ loginForm.email().value() }}</p>

Al ser signals, se puede hacer set() tanto a fields individuales como a todo el form:

// Update the value programmatically
loginForm.email().value.set('alice@wonderland.com');

this.userModel.set({name: 'Alice', email: 'alice@example.com', age: 30 });

En todo momento se tiene acceso al modelo y al formulario, los cuales están sincronizados reactivamente. Además se puede convertir a formData:

const formData = this.loginModel();

Validar formularios con signal()#

A la función form() se le puede añadir un función de schema, la cual acepta validadores:

const loginForm = form(loginModel, (schemaPath) => {
  debounce(schemaPath.email, 500);
  required(schemaPath.email, {message: 'Email is required'});
  email(schemaPath.email, {message: 'Please enter a valid email address'});
});

Validadores:

  • required() - Ensures the field has a value

  • email() - Validates email format

  • min() / max() - Validates number ranges

  • minLength() / maxLength() - Validates string or collection length

  • pattern() - Validates against a regex pattern

Hay otras funciones:

  • disabled() que evalua si debe estar deshabilitado

  • hidden() si debe ser ocultado

  • readonly()

Puesto que cada field expone su estado de validació, este puede ser usado:

    <label>
      Email:
      <input type="email" [field]="loginForm.email" />
    </label>
    @if (loginForm.email().touched() && loginForm.email().invalid()) {
      <ul class="error-list">
        @for (error of loginForm.email().errors(); track error) {
          <li>{{ error.message }}</li>
        }
      </ul>
    }

El estado puede ser:

  • valid() Returns true if the field passes all validation rules

  • touched() Returns true if the user has focused and blurred the field

  • dirty() Returns true if the user has changed the value

  • disabled() Returns true if the field is disabled

  • readonly() Returns true if the field is readonly

  • pending() Returns true if async validation is in progress

  • errors() Returns an array of validation errors with kind and message properties

Modelos avanzados#

Se pueden tener nested models:

// Nested structure
const userModel = signal({
  name: '',
  email: '',
  address: {
    street: '',
    city: '',
    state: '',
    zip: '',
  },
});

Esto permite sobretodo validar grupos como una unidad o adaptarlo a la estructura que viene de la API.

También se pueden tener arrays:

const orderModel = signal({
  customerName: '',
  items: [{product: '', quantity: 0, price: 0}],
});
const orderForm = form(orderModel);
// Access array items by index
orderForm.items[0].product; // FieldTree<string>
orderForm.items[0].quantity; // FieldTree<number>

Lo cual permite tener formularios dinámicos con colecciones de datos.

Validadores personalizados#

Dentro de la función schemaPath se puede poner la función validate():

  urlModel = signal({ website: '' })
  urlForm = form(this.urlModel, (schemaPath) => {
    validate(schemaPath.website, ({value}) => {
      if (!value().startsWith('https://')) {
        return {
          kind: 'https',
          message: 'URL must start with https://'
        }
      }
      return null
    })
  })

Veamos la función de validación, esta recibe un objeto del tipo FieldContext que tiene:

  • value Signal Signal containing the current field value

  • state FieldState The field state reference

  • field FieldTree The field tree reference

  • valueOf() Method Get the value of another field by path

  • stateOf() Method Get the state of another field by path

  • fieldTreeOf() Method Get the field tree of another field by path

  • pathKeys Signal Path keys from root to current field

Esta función del ejemplo era anónima. Si quisieramos hacerla más genérica para reutilizarla la podemos declarar con nombre antes y usar sin problemas:

function url(field: any, options?: {message?: string}) {
  validate(field, ({value}) => {
    try {
      new URL(value());
      return null;
    } catch {
      return {
        kind: 'url',
        message: options?.message || 'Enter a valid URL',
      };
    }
  });
}
function phoneNumber(field: any, options?: {message?: string}) {
  validate(field, ({value}) => {
    const phoneRegex = /^\d{3}-\d{3}-\d{4}$/;
    if (!phoneRegex.test(value())) {
      return {
        kind: 'phoneNumber',
        message: options?.message || 'Phone must be in format: 555-123-4567',
      };
    }
    return null;
  });
}


urlForm = form(this.urlModel, (schemaPath) => {
  url(schemaPath.website, {message: 'Please enter a valid website URL'});
  phoneNumber(schemaPath.phone);
});

Cross Form validation#

  passwordModel = signal({
    password: '',
    confirmPassword: ''
  })
  passwordForm = form(this.passwordModel, (schemaPath) => {
    required(schemaPath.password, { message: 'Password is required' })
    minLength(schemaPath.password, 8, { message: 'Password must be at least 8 characters' })
    required(schemaPath.confirmPassword, { message: 'Please confirm your password' })
    validate(schemaPath.confirmPassword, ({value, valueOf}) => {
      const confirmPassword = value()
      const password = valueOf(schemaPath.password)
      if (confirmPassword !== password) {
        return {
          kind: 'passwordMismatch',
          message: 'Passwords do not match'
        }
      }
      return null
    })
  })

https://angular.dev/guide/forms/signals/overview

Ficheros en Formularios#

Si ponemos un input de tipo file, lo podemos tratar como en el ejemplo:

selectedFile: File | null = null;

onFileSelected(event: any) {
    this.selectedFile = event.target.files[0];
  }

onSubmit() {
    if (this.selectedFile && this.nombre && this.email) {
      this.uploadService.uploadForm(this.selectedFile, this.nombre, this.email).subscribe({
        next: (response) => console.log('Subida exitosa:', response),
        error: (error) => console.error('Error al subir:', error)
      });
    } else {
      console.error('Todos los campos son obligatorios');
    }
  }

Siendo onFileSelected y onSubmit los manejadores de los eventos <form (submit)="onSubmit()"> y <input type="file" (change)="onFileSelected($event)" required />

Cuando hay ficheros, no importa que se trate de formularios reactivos o de plantilla, ya que los FormControl no manejar ficheros. Hay que atender al evento de subida del fichero y tratarlo por separado del FormGroup.