---
title: "Creating Dynamic Forms in Angular: A Step-by-Step Guide"
date: "2022-11-07"
slug: "creating-dynamic-forms-in-angular-a-step-by-step-guide"
author: "Dany Paredes"
canonical: "https://danywalls.com/creating-dynamic-forms-in-angular-a-step-by-step-guide"
description: "Learn how to build data-driven, typed dynamic forms in modern Angular from configuration models using Reactive Forms, Control Flow (@switch, @for), and validation."
---


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:

| Concern | Static Form | Dynamic Form |
|:---|:---|:---|
| Adding a new field | Edit TypeScript + HTML + tests | Add one object to schema array |
| Loading fields from API | Requires template rewrite | Pass API response directly |
| Reusing across features | Duplicate code | Single engine component |
| Initial complexity | Low | Moderate |

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:

```typescript
// 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":

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
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](/understand-composition-and-inheritance-in-angular) and [Essential Angular Interview Questions](/essential-angular-questions-for-junior-and-mid-level-job-interviews)!

