Understanding Dynamic Forms and Standalone Components

Almost every web application relies on forms, whether for user registration, data collection, or feedback submission. Angular's Reactive Forms provide a solid foundation for static forms, but real-world applications often need forms that can change and evolve based on user input or external data sources.

This post explores how to construct adaptive forms with Angular 19's standalone components, enabling a more modular setup without relying on traditional NgModule structures. The companion GitHub repository includes Tailwind CSS for visual styling, but our examples deliberately omit these style-specific details to concentrate solely on the dynamic form mechanics. All Tailwind-related configurations and classes are left out to keep the discussion focused and relevant.

Dynamic Forms in Angular 19: Creating Flexible and Scalable User Interfaces with Standalone Components — figure 1


The Concept of Dynamic Forms

A dynamic form is one whose structure — including fields, validation rules, and layout — is determined during runtime rather than at compile time. This approach proves invaluable in several common situations:

  • Wizards and multi-step processes that require steps to appear conditionally.
  • Forms that are built from data received via API calls.
  • Customizable forms where users have the ability to add new fields or remove existing ones on the fly.

Advantages of Standalone Components

Standalone components streamline Angular development by eliminating the need for NgModule wrappers. Dependencies such as Reactive Forms or routing can be imported directly into the component, which leads to:

  • Less boilerplate code to maintain.
  • Improved encapsulation and reusability.
  • Accelerated development workflows.

Angular's built-in FormArray and FormGroup services make it straightforward to handle these flexible forms, providing the ability to add, remove, or modify controls dynamically without friction.

Step-by-Step Guide to Building Dynamic Forms

1. Setting Up a New Angular Project

To get started, we’ll set up a fresh Angular workspace from scratch.

npm install @angular/cli

Next, generate a new Angular application. When prompted, pick SCSS for the stylesheet format and choose No for SSR.

ng new dynamic-forms-sample-app

2. Constructing the Dynamic Form

For dynamic form generation, we rely on FormGroup and FormArray from Angular's Reactive Forms module. Below is the complete implementation:

Component Logic

import { Component } from "@angular/core";
import {
  FormBuilder,
  FormGroup,
  FormArray,
  Validators,
  ReactiveFormsModule,
} from "@angular/forms";

@Component({
  selector: "app-root",
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.scss"],
})
export class AppComponent {
  dynamicForm: FormGroup; // Main form group

  constructor(private fb: FormBuilder) {
    this.dynamicForm = this.fb.group({
      name: [""], // Simple input field
      email: [""], // Another input field
      fields: this.fb.array([]), // Dynamic fields will be stored here
    });
  }

  // Getter to access the FormArray for dynamic fields
  get fields(): FormArray {
    return this.dynamicForm.get("fields") as FormArray;
  }

  /**
   * Adds a new field to the dynamic form.
   */
  addField() {
    const fieldGroup = this.fb.group({
      label: [""], // Label for the field
      value: [""], // Value of the field
    });
    this.fields.push(fieldGroup);
  }

  /**
   * Removes a field from the dynamic form at a specific index.
   * @param index Index of the field to be removed.
   */
  removeField(index: number) {
    this.fields.removeAt(index);
  }

  /**
   * Submits the form and logs its current value to the console.
   */
  submitForm() {
    console.log(this.dynamicForm.value);
  }
}

Method Overview

  1. addField()

    This function generates a new form group containing two controls: label and value. It then appends this group to the fields array, enabling users to add extra fields on the fly.
  2. removeField(index: number)

    This function eliminates a form group from the fields array at the given index. It’s handy when a user wants to discard a field they no longer require.
  3. submitForm()

    This function captures the current form state and outputs it. In a production scenario, you would typically transmit this data to a backend or use it to refresh the UI.

Template Implementation

Clear out app.component.html and insert the following markup. The template dynamically renders form controls along with buttons to add or remove fields.

<form [formGroup]="dynamicForm" (ngSubmit)="submitForm()">
  <div>
    <label>Name:</label>
    <input formControlName="name" />
  </div>

  <div>
    <label>Email:</label>
    <input formControlName="email" />
  </div>

  <div formArrayName="fields">
    @for(field of fields.controls; let i = $index; track field) {
      <div [formGroupName]="i">
        <label>
          Label:
          <input formControlName="label" />
        </label>
        <label>
          Value:
          <input formControlName="value" />
        </label>
        <button type="button" (click)="removeField(i)">Remove</button>
      </div>
    }
  </div>

  <button type="button" (click)="addField()">Add Field</button>
  <button type="submit">Submit</button>
</form>

Template Explanation

  1. Static Controls (Name and Email)

    These controls are always visible and are bound using formControlName.
  2. Dynamic Controls (fields)
  • The @for directive iterates over the FormArray to render each dynamic control.
  • Each control uses formControlName bindings for both its label and value.
  1. Action Buttons
    • The "Add Field" button triggers addField() to introduce a new dynamic control.
    • Every dynamic control includes a "Remove" button that invokes removeField().

Generating a Form from API Responses

Frequently, the layout of a form originates from an external source, like a server-side configuration.

Retrieving Form Configuration

Assume an API delivers the following JSON structure:

{
  "fields": [
    { "label": "Username", "type": "text", "required": true },
    { "label": "Age", "type": "number", "required": false },
    {
      "label": "Gender",
      "type": "select",
      "options": ["Male", "Female"],
      "required": true
    }
  ]
}

Rendering Fields Dynamically

Here’s how you can construct the form based on that configuration:

import { Component, OnInit } from "@angular/core";
import { FormBuilder, FormGroup, Validators } from "@angular/forms";

@Component({
  selector: "app-dynamic-api-form",
  templateUrl: "./dynamic-api-form.component.html",
})
export class DynamicApiFormComponent implements OnInit {
  dynamicForm!: FormGroup; // The main reactive form instance
  formConfig: any; // The configuration object fetched from the API

  constructor(private fb: FormBuilder) {}

  ngOnInit() {
    this.fetchFormConfig().then((config) => {
      this.formConfig = config;
      this.buildForm(config.fields); // Build the form based on the configuration
    });
  }

  /**
   * Simulates fetching form configuration from an API.
   * In a real application, this would be an HTTP request.
   */
  async fetchFormConfig() {
    // Simulate API call
    return {
      fields: [
        { label: "Username", type: "text", required: true },
        { label: "Age", type: "number", required: false },
        {
          label: "Gender",
          type: "select",
          options: ["Male", "Female"],
          required: true,
        },
      ],
    };
  }

  /**
   * Dynamically creates the form controls based on the fetched configuration.
   */
  buildForm(fields: any[]) {
    const controls: any = {};
    fields.forEach((field) => {
      const validators = field.required ? [Validators.required] : [];
      controls[field.label] = ["", validators];
    });
    this.dynamicForm = this.fb.group(controls);
  }

  /**
   * Handles form submission, logging the form value to the console.
   */
  submitForm() {
    console.log(this.dynamicForm.value);
  }
}

Detailed Code Walkthrough

Properties

  1. dynamicForm
  • Stores the primary FormGroup instance for the reactive form.
  • It is assembled dynamically from the API response.
  1. formConfig
    • Holds the configuration object retrieved from the API.
    • Specifies fields, their types, validation constraints, and options (if any).

Methods

  1. ngOnInit()
    • Lifecycle hook that executes following component initialization.
    • Invokes fetchFormConfig() to acquire the form config and build the form.

  1. fetchFormConfig()
  • Mimics an API call to obtain form configuration. In a live environment, replace this mock with a genuine HTTP request to retrieve the config.

Sample Configuration:

   {
     fields: [
       { label: "Name", type: "text", required: true },
       { label: "Age", type: "number", required: true },
       {
         label: "Gender",
         type: "select",
         required: true,
         options: ["Male", "Female", "Other"],
       },
     ];
   }

  1. buildForm()
  • Builds the FormGroup dynamically using the fetched configuration.
  • For each field in the config:
    • Adds a FormControl to the FormGroup.
    • Applies validators (like Validators.required) when the field is marked as required.

Example Structure (FormGroup):

   {
     Name: ['', [Validators.required]],
     Age: ['', [Validators.required]],
     Gender: ['', [Validators.required]]
   }

  1. submitForm()
  • Fires when the user submits the form.
  • If the form is valid:
    • Logs the form values to the console.
  • If invalid:
    • Logs an error message.

Sample Output (Form Values):

   {
     Name: 'John Doe',
     Age: 30,
     Gender: 'Male'
   }

How Everything Fits Together

  1. Initialization
  • Upon component load, ngOnInit() calls fetchFormConfig() to simulate retrieving a form structure.
  1. Form Assembly
  • buildForm() leverages the fetched configuration to dynamically create a reactive form.
  1. User Engagement
  • The form is presented using the associated HTML.
  • Users can enter values or make selections depending on the field types.
  1. Validation
  • Form controls enforce validation rules (e.g., required fields).
  • Invalid fields display error messages once touched.
  1. Submission
    • On submit, submitForm() evaluates validity and processes the form values as needed.

This breakdown clarifies the role and functionality of every method and property in the TypeScript code.

Template:

@if(dynamicForm) {
  <form [formGroup]="dynamicForm" (ngSubmit)="submitForm()">
    @for(field of formConfig.fields; track field) {

      <label>{{ field.label }}</label>
      @switch(field.type) { 
        @case('text') {
          <input [formControlName]="field.label" />
        } 
        @case('number') {
          <input
            [formControlName]="field.label"
            type="number"
          />
        } 
        @case('select') {
          <select [formControlName]="field.label">
            @for(option of field.options; track option) {
              <option [value]="option">
                {{ option }}
              </option>
            }
          </select>
        } 
      } 
    }
    <button type="submit">Submit</button>
  </form>
}

Template Structural Analysis

  1. @if(dynamicForm)

    Guarantees the form appears only after it’s been built with the retrieved configuration.
  2. @for(field of formConfig.fields; track field)

    Loops over the fields array from the configuration to render form controls dynamically.
  3. @switch(field.type)

    Chooses the appropriate form control type dynamically based on the type property in the field config (e.g., text, number, or select).
  4. Control Types
  • Text Controls (@case('text')) Renders an <input> element for text fields.
  • Number Controls (@case('number')) Renders an <input> element with type="number".
  • Select Controls (@case('select')) Renders a <select> element, populating its options from the options array in the field configuration.
  1. Validation Messaging
  • @if(dynamicForm.get(field.label)?.invalid && dynamicForm.get(field.label)?.touched && dynamicForm.get(field.label)?.hasError('required')) Presents validation error messages only when the field is invalid and has been interacted with.
  1. Submit Button
    • [disabled]="dynamicForm.invalid" Keeps the submit button inactive until all required fields pass validation.

This template ensures the dynamic form adapts completely to the fetched configuration while delivering real-time validation feedback for required fields.


Adjusting Validators Dynamically

Validators can also be changed on the fly based on user actions or specific conditions.

Example:

onRoleChange(role: string) {
  const emailControl = this.dynamicForm.get('email');
  if (role === 'admin') {
    emailControl?.setValidators([Validators.required, Validators.email]);
  } else {
    emailControl?.clearValidators();
  }
  emailControl?.updateValueAndValidity();
}

Wrapping Up

Dynamic forms within Angular provide a versatile approach for crafting highly interactive and scalable interfaces. By utilizing FormArray, FormGroup, and API-driven configurations, you can build forms that respond to user requirements while preserving robustness and performance. You can access the repository (styled with Tailwind) here: https://github.com/sonukapoor/dynamic-forms-sample-app

Apply these strategies to construct more intelligent forms that benefit your users and streamline your code. Happy building!

Let's Stay Connected

Did this walkthrough add value for you? Let's keep the conversation going:

🔗 Connect with me on LinkedIn
💻 Explore my projects on GitHub
Show your support with a coffee