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

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:
// 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:
- A typed schema model →
DynamicFieldConfig[] - A form builder service → maps schema to
FormGroupwith validators - A renderer component → uses
@switchto render the right input type - Specific error messages → improves UX over generic "invalid" messages
- 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!
Modern frontend development — Angular, React, TypeScript, accessibility, and performance.
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
Essential Angular Interview Questions for Junior and Mid-Level Developers
A practical guide to modern Angular interview questions for junior and mid-level roles, covering Signals, Standalone APIs, Control Flow, Vitest, and Zoneless Angular.
Combine Async Pipes in Angular: From combineLatest to Signals and @let
Learn how to avoid multiple async pipes and duplicate subscriptions in Angular templates using combineLatest, modern Signals (toSignal), and the @let template syntax.
Building Angular Forms with GolemUI and Kendo UI
Working with forms is a big part of my daily job. I built golemui-kendo to combine GolemUI's schema-driven forms with Kendo UI's enterprise-grade widgets into a single ng add command.
Understanding Composition vs. Inheritance in Angular
Why class inheritance often leads to constructor hell in Angular, and how to use the Directive Composition API (hostDirectives), inject(), and Signals for clean code.
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.
Join 13,800+ developers and readers.
No spam ever. Unsubscribe at any time.