Frontend
·7 min read·↗

Creating Dynamic Forms in Angular: A Step-by-Step Guide

Main cover illustration for article: Creating Dynamic Forms in Angular: A Step-by-Step Guide
Summarize with AI:

Every Angular developer eventually faces this situation: marketing wants two new checkout fields next week, the backend team changes the form schema weekly, or you need to render user-configured survey forms from a database.

Hardcoding each field by hand is not maintainable. What you need is a form that builds itself from a configuration object.

Here is what we will build — a fully typed dynamic form engine that takes a schema array and renders the correct input type with validation, all from a single component:

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

Let's build it step by step.


At a Glance: Static Form vs. Dynamic Form 📊

Before writing code, understand the trade-off you are making:

ConcernStatic FormDynamic Form
Adding a new fieldEdit TypeScript + HTML + testsAdd one object to schema array
Loading fields from APIRequires template rewritePass API response directly
Reusing across featuresDuplicate codeSingle engine component
Initial complexityLowModerate

Dynamic forms pay off quickly in apps where form structure is driven by data or changes frequently.


Step 1: Defining the Field Schema & Types 📐

Create a TypeScript interface that describes every possible field configuration:

// 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[]; // For select, radio, checkbox
  required?: boolean;
  min?: number;
  max?: number;
}

Now describe an entire registration form in a single array — this is your "source of truth":

// 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,
  },
];

Now that our schema is defined, let's build the FormGroup builder.


Step 2: Building the Reactive FormGroup Programmatically ⚙️

Create a service that takes the schema array and converts it into a typed Angular FormGroup with automatic validation:

// 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);
  }
}

Notice that adding a new validation rule only requires updating the DynamicFieldConfig interface and this service — zero template changes.

Let's build the template renderer next.


Step 3: Creating the Dynamic Field Renderer 🎨

Create a presentational <app-dynamic-field> component. It uses Angular's modern @switch control flow to render the right input for each field type:

// 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>
        }
      }
 
      <!-- Validation error feedback -->
      @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);
  }
}

Notice the specific error messages — required, min, and email are handled separately. This immediately improves UX and reduces user frustration.

Now let's assemble the container.


Step 4: Assembling the Dynamic Form Container 🏗️

The <app-dynamic-form> component receives the schema, generates the FormGroup, renders all fields, and emits the submitted values:

// 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);
    }
  }
}

Step 5: Using the Dynamic Form in Your App 🚀

In your parent page, pass the schema and handle the submitted output:

// 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);
    // POST to your backend API
  }
}

When marketing requests new fields or changes validation rules, you update REGISTRATION_SCHEMA (or fetch it from an API) — no template changes needed, ever.


Handling Conditional Fields with Signals 💡

A common real-world requirement: show "Province" only when "Country" is "Canada". With Signals, this is clean:

export class DynamicFormComponent implements OnInit {
  private formService = inject(DynamicFormService);
 
  schema = input.required<DynamicFieldConfig[]>();
  form!: FormGroup;
 
  ngOnInit() {
    this.form = this.formService.buildFormGroup(this.schema());
 
    // Listen for country changes and conditionally add/remove "province" control
    this.form.get('country')?.valueChanges.subscribe((country) => {
      if (country === 'CA') {
        this.form.addControl('province', new FormControl('', Validators.required));
      } else {
        this.form.removeControl('province');
      }
    });
  }
}

The schema renders whatever controls exist in the FormGroup — the field automatically appears and disappears without touching the template.


Recap 🛠️

You built a fully functional dynamic form engine with:

  1. A typed schema model → DynamicFieldConfig[]
  2. A form builder service → maps schema to FormGroup with validators
  3. A renderer component → uses @switch to render the right input type
  4. Specific error messages → improves UX over generic "invalid" messages
  5. Conditional field support → powered by valueChanges + addControl/removeControl

For more modern Angular architecture techniques, check out my guides on Composition vs. Inheritance in Angular and Essential Angular Interview Questions!

Part of the Frontend Series

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

View Entire Series

Frequently Asked Questions

How do I build a dynamic form in modern Angular using Reactive Forms?

Define a TypeScript model describing your form fields (type, label, defaultValue, validators, options). In your component, iterate over the schema to create FormControl instances within a FormGroup. In the template, use modern control flow (@for and @switch) to render matching inputs.

What is the benefit of dynamic forms over hardcoded forms in Angular?

Dynamic forms decouple your UI from rigid template markup. When business requirements add new fields or alter validation rules, you only modify the configuration schema — or receive it from a backend API — without changing component code.

How do you handle different input types (text, select, radio, checkbox) dynamically?

Use the built-in @switch (field.type) block in the dynamic field template to render the appropriate control (<input>, <select>, radio groups, or checkboxes), all bound via [formControlName]='field.name'.

How do you apply dynamic validators to FormControls in Angular?

Map string-based validation rules from your field configuration (like 'required' or 'minLength:5') to standard Angular Validators (Validators.required, Validators.minLength(5)) when instantiating the FormControl.

Can a dynamic form schema come from a backend API?

Yes. Because the schema is just a plain TypeScript array of objects, you can fetch it via HttpClient, map the JSON response to your DynamicFieldConfig[] type, and pass it directly to the form builder. No template changes are needed.

Related Articles

Share this article

If you found this guide helpful, consider sharing it with your team or fellow developers.


Real Software. Real Lessons.

I share the lessons I learned the hard way, so you can either avoid them or be ready when they happen.

User avatar
User avatar
User avatar
User avatar
+13K

Join 13,800+ developers and readers.

No spam ever. Unsubscribe at any time.