🇺🇸 EN
Frontend
·8 min read·↗·🇺🇸 EN

Cómo crear formularios dinámicos en Angular: Guía paso a paso

Portada principal del artículo: Cómo crear formularios dinámicos en Angular: Guía paso a paso

Todo desarrollador de Angular se enfrenta tarde o temprano a esta situación: el equipo de marketing pide dos campos nuevos en el checkout para la próxima semana, el equipo de backend cambia el esquema de datos cada pocos días, o necesitas renderizar encuestas configurables por el usuario directamente desde una base de datos.

Escribir cada campo a mano en la plantilla no es sostenible. Lo que necesitas es un formulario que se construya solo a partir de un objeto de configuración.

Esto es lo que vamos a construir: un motor de formularios dinámicos totalmente tipado que toma un arreglo con la configuración del esquema y renderiza el tipo de campo correcto con sus validaciones, todo desde un único componente:

Schema Array ──► FormGroup Builder ──► Dynamic Field Renderer ──► Submitted Values
[{ name: 'email', type: 'email', required: true }]

Vamos a construirlo paso a paso.


De un vistazo: Formulario estático vs. Formulario dinámico 📊

Antes de escribir código, vale la pena entender los pros y contras de cada enfoque:

AspectoFormulario estáticoFormulario dinámico
Agregar un nuevo campoModificar TypeScript + HTML + pruebasAgregar un objeto al arreglo de configuración
Cargar campos desde una APIRequiere reescribir la plantillaPasar la respuesta de la API directamente
Reutilizar entre funcionalidadesDuplicar códigoUn único componente como motor
Complejidad inicialBajaModerada

Los formularios dinámicos valen la pena rápidamente en aplicaciones donde la estructura del formulario depende de datos o cambia con frecuencia.


Paso 1: Definir el esquema y los tipos de los campos 📐

Primero, crea una interfaz de TypeScript que describa la configuración de cada campo posible:

// dynamic-field.model.ts
export type FieldType = 'text' | 'number' | 'email' | 'select' | 'radio' | 'checkbox';
 
export interface FieldOption {
  label: string;
  value: string | number;
}
 
export interface DynamicFieldConfig {
  name: string;
  label: string;
  type: FieldType;
  value?: unknown;
  placeholder?: string;
  options?: FieldOption[]; // Para select, radio, checkbox
  required?: boolean;
  min?: number;
  max?: number;
}

Ahora describe un formulario de registro completo en un único arreglo. Esta será tu fuente de la verdad:

// registration-form.config.ts
import { DynamicFieldConfig } from './dynamic-field.model';
 
export const REGISTRATION_SCHEMA: DynamicFieldConfig[] = [
  {
    name: 'firstName',
    label: 'First Name',
    type: 'text',
    placeholder: 'Enter your first name',
    required: true,
  },
  {
    name: 'lastName',
    label: 'Last Name',
    type: 'text',
    placeholder: 'Enter your last name',
    required: true,
  },
  {
    name: 'age',
    label: 'Age',
    type: 'number',
    min: 18,
    required: true,
  },
  {
    name: 'country',
    label: 'Country',
    type: 'select',
    required: true,
    options: [
      { label: 'United States', value: 'US' },
      { label: 'Spain', value: 'ES' },
      { label: 'Canada', value: 'CA' },
    ],
  },
  {
    name: 'subscribe',
    label: 'Subscribe to Developer Newsletter',
    type: 'checkbox',
    value: true,
  },
];

Con el esquema ya definido, pasemos a construir el servicio que genera el FormGroup.


Paso 2: Crear el FormGroup reactivo de forma programática ⚙️

Crea un servicio que tome el arreglo de configuración y lo convierta en un FormGroup de Angular con sus validaciones automáticas:

// dynamic-form.service.ts
import { Injectable } from '@angular/core';
import { FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms';
import { DynamicFieldConfig } from './dynamic-field.model';
 
@Injectable({ providedIn: 'root' })
export class DynamicFormService {
  buildFormGroup(schema: DynamicFieldConfig[]): FormGroup {
    const group: Record<string, FormControl> = {};
 
    schema.forEach((field) => {
      const validators: ValidatorFn[] = [];
 
      if (field.required) validators.push(Validators.required);
      if (field.min !== undefined) validators.push(Validators.min(field.min));
      if (field.type === 'email') validators.push(Validators.email);
 
      group[field.name] = new FormControl(field.value ?? '', {
        validators,
        nonNullable: field.type === 'checkbox',
      });
    });
 
    return new FormGroup(group);
  }
}

Fíjate en un detalle importante: para agregar una nueva regla de validación, solo necesitas actualizar la interfaz DynamicFieldConfig y este servicio, con cero cambios en tus plantillas HTML.

A continuación, vamos a crear el componente que renderiza cada campo en la plantilla.


Paso 3: Crear el renderizador dinámico de campos 🎨

Crea un componente de presentación llamado <app-dynamic-field>. Este componente utiliza la sintaxis moderna de control flow de Angular con @switch para renderizar el control adecuado según el tipo de campo:

// dynamic-field.component.ts
import { Component, input } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { DynamicFieldConfig } from './dynamic-field.model';
 
@Component({
  selector: 'app-dynamic-field',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <div [formGroup]="form()" class="field-container">
      <label [for]="field().name" class="field-label">
        {{ field().label }}
        @if (field().required) { <span class="required-asterisk">*</span> }
      </label>
 
      @switch (field().type) {
        @case ('text') {
          <input
            [id]="field().name"
            type="text"
            [formControlName]="field().name"
            [placeholder]="field().placeholder || ''"
            class="form-input"
          />
        }
        @case ('number') {
          <input
            [id]="field().name"
            type="number"
            [formControlName]="field().name"
            class="form-input"
          />
        }
        @case ('email') {
          <input
            [id]="field().name"
            type="email"
            [formControlName]="field().name"
            [placeholder]="field().placeholder || ''"
            class="form-input"
          />
        }
        @case ('select') {
          <select [id]="field().name" [formControlName]="field().name" class="form-select">
            <option value="" disabled>Select an option...</option>
            @for (opt of field().options ?? []; track opt.value) {
              <option [value]="opt.value">{{ opt.label }}</option>
            }
          </select>
        }
        @case ('checkbox') {
          <div class="checkbox-wrapper">
            <input
              [id]="field().name"
              type="checkbox"
              [formControlName]="field().name"
            />
            <span>{{ field().label }}</span>
          </div>
        }
      }
 
      <!-- Mensajes de error de validación -->
      @if (control()?.invalid && (control()?.touched || control()?.dirty)) {
        <p class="error-message">
          @if (control()?.errors?.['required']) { This field is required. }
          @else if (control()?.errors?.['min']) { Value must be at least {{ field().min }}. }
          @else if (control()?.errors?.['email']) { Enter a valid email address. }
        </p>
      }
    </div>
  `,
  styles: [`
    .field-container { margin-bottom: 1.25rem; }
    .field-label { display: block; font-weight: 600; margin-bottom: 0.35rem; }
    .required-asterisk { color: #e11d48; margin-left: 0.25rem; }
    .form-input, .form-select {
      width: 100%;
      padding: 0.6rem 0.8rem;
      border: 1px solid #d1d5db;
      border-radius: 0.5rem;
      font-size: 1rem;
    }
    .form-input:focus, .form-select:focus {
      outline: 2px solid #6366f1;
      border-color: transparent;
    }
    .error-message { color: #e11d48; font-size: 0.85rem; margin-top: 0.25rem; }
  `]
})
export class DynamicFieldComponent {
  field = input.required<DynamicFieldConfig>();
  form = input.required<FormGroup>();
 
  get control() {
    return () => this.form().get(this.field().name);
  }
}

Observa los mensajes de error específicos: manejamos required, min y email por separado. Esto mejora la experiencia de usuario y evita confusiones al rellenar el formulario.

Ahora vamos a armar el componente contenedor.


Paso 4: Armar el contenedor del formulario dinámico 🏗️

El componente <app-dynamic-form> recibe el esquema, genera el FormGroup, renderiza todos los campos y emite los valores enviados cuando el usuario completa el formulario:

// dynamic-form.component.ts
import { Component, input, output, inject, OnInit } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { DynamicFieldConfig } from './dynamic-field.model';
import { DynamicFormService } from './dynamic-form.service';
import { DynamicFieldComponent } from './dynamic-field.component';
 
@Component({
  selector: 'app-dynamic-form',
  standalone: true,
  imports: [ReactiveFormsModule, DynamicFieldComponent],
  template: `
    @if (form) {
      <form [formGroup]="form" (ngSubmit)="onSubmit()" class="dynamic-form">
        @for (field of schema(); track field.name) {
          <app-dynamic-field [field]="field" [form]="form" />
        }
 
        <button type="submit" [disabled]="form.invalid" class="submit-btn">
          Submit Form
        </button>
      </form>
    }
  `,
  styles: [`
    .dynamic-form { max-width: 500px; padding: 1.5rem; border-radius: 1rem; }
    .submit-btn {
      width: 100%;
      padding: 0.75rem;
      background-color: #0f172a;
      color: white;
      font-weight: bold;
      border-radius: 0.5rem;
      cursor: pointer;
      transition: opacity 0.2s;
    }
    .submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
    .submit-btn:not(:disabled):hover { opacity: 0.85; }
  `]
})
export class DynamicFormComponent implements OnInit {
  private formService = inject(DynamicFormService);
 
  schema = input.required<DynamicFieldConfig[]>();
  formSubmitted = output<Record<string, unknown>>();
 
  form!: FormGroup;
 
  ngOnInit() {
    this.form = this.formService.buildFormGroup(this.schema());
  }
 
  onSubmit() {
    if (this.form.valid) {
      this.formSubmitted.emit(this.form.value);
    }
  }
}

Paso 5: Usar el formulario dinámico en tu aplicación 🚀

En tu componente padre o página principal, pasa el esquema y maneja el evento con los datos enviados:

// app.component.ts
import { Component } from '@angular/core';
import { DynamicFormComponent } from './dynamic-form.component';
import { REGISTRATION_SCHEMA } from './registration-form.config';
 
@Component({
  selector: 'app-root',
  standalone: true,
  imports: [DynamicFormComponent],
  template: `
    <main class="page-wrapper">
      <h1>User Registration</h1>
      <app-dynamic-form
        [schema]="schema"
        (formSubmitted)="handleFormSubmission($event)"
      />
    </main>
  `
})
export class AppComponent {
  schema = REGISTRATION_SCHEMA;
 
  handleFormSubmission(formData: Record<string, unknown>) {
    console.log('✅ Form submitted:', formData);
    // Enviar a tu API backend
  }
}

Cuando marketing pida nuevos campos o modifique reglas de validación, solo actualizas REGISTRATION_SCHEMA (o lo descargas desde una API), sin tocar jamás las plantillas HTML.


Manejar campos condicionales con Signals 💡

Un requisito muy común en proyectos reales es mostrar campos de forma condicional, por ejemplo, mostrar "Provincia" únicamente cuando el "País" seleccionado sea "Canadá". Con Signals y Formularios Reactivos, esto se resuelve de forma muy limpia:

export class DynamicFormComponent implements OnInit {
  private formService = inject(DynamicFormService);
 
  schema = input.required<DynamicFieldConfig[]>();
  form!: FormGroup;
 
  ngOnInit() {
    this.form = this.formService.buildFormGroup(this.schema());
 
    // Escuchar cambios en el país y agregar o eliminar el control "province" dinámicamente
    this.form.get('country')?.valueChanges.subscribe((country) => {
      if (country === 'CA') {
        this.form.addControl('province', new FormControl('', Validators.required));
      } else {
        this.form.removeControl('province');
      }
    });
  }
}

El formulario renderiza los controles que existan en el FormGroup: el campo aparece y desaparece de forma automática sin necesidad de tocar la plantilla.


Resumen 🛠️

Acabas de construir un motor de formularios dinámicos completo y funcional con:

  1. Un modelo de esquema tipado → DynamicFieldConfig[]
  2. Un servicio generador de formularios → mapea la configuración a un FormGroup con validaciones
  3. Un componente renderizador → usa @switch para mostrar el tipo de entrada correcto
  4. Mensajes de error específicos → mejora la experiencia de usuario frente a mensajes genéricos
  5. Soporte para campos condicionales → basado en valueChanges con addControl y removeControl

Si quieres conocer más técnicas de arquitectura en Angular moderno, te invito a leer mis guías sobre Composición vs. Herencia en Angular y Preguntas esenciales de Angular para entrevistas técnicas.

Parte de la serie Frontend

Modern frontend development — Angular, React, TypeScript, accessibility, and performance.

Ver serie completa

Preguntas Frecuentes

¿Cómo construyo un formulario dinámico en Angular moderno usando Formularios Reactivos?

Define un modelo en TypeScript que describa los campos de tu formulario (tipo, etiqueta, valor inicial, validadores y opciones). En tu componente, itera sobre el esquema para crear instancias de FormControl dentro de un FormGroup. En la plantilla, usa el control flow moderno (@for y @switch) para renderizar los campos correspondientes.

¿Cuál es la ventaja de los formularios dinámicos frente a formularios estáticos en Angular?

Los formularios dinámicos desacoplan tu interfaz de usuario del marcado rígido de las plantillas. Cuando los requisitos del negocio agregan nuevos campos o cambian reglas de validación, solo modificas el esquema de configuración (o lo recibes directamente desde una API backend) sin tener que tocar el código del componente.

¿Cómo se manejan diferentes tipos de entrada (texto, select, radio, checkbox) de forma dinámica?

Usa el bloque nativo @switch (field.type) en la plantilla del campo dinámico para renderizar el control adecuado (<input>, <select>, grupos de radio o casillas de verificación), todos vinculados mediante [formControlName]='field.name'.

¿Cómo se aplican validadores dinámicos a los FormControls en Angular?

Mapea las reglas de validación basadas en texto desde tu configuración de campos (como 'required' o 'minLength:5') a los validadores estándar de Angular (Validators.required, Validators.minLength(5)) al momento de instanciar cada FormControl.

¿Puede venir el esquema de un formulario dinámico desde una API backend?

Sí. Como el esquema es solo un arreglo de objetos en TypeScript, puedes obtenerlo con HttpClient, mapear la respuesta JSON al tipo DynamicFieldConfig[] y pasarlo directamente al generador del formulario. No necesitas modificar nada en la plantilla.

Artículos Relacionados

Compartir este artículo

Si esta guía te resultó útil, compártela con tu equipo o con otros desarrolladores.


Software real. Lecciones reales.

Comparto las lecciones que aprendí por las malas, para que puedas evitarlas o estar preparado cuando ocurran.

User avatar
User avatar
User avatar
User avatar
+13K

Únete a 13,800+ desarrolladores y lectores.

Cero spam. Cancela tu suscripción en cualquier momento.