Implementing Dynamic Forms in Angular

Let me walk you through how I would implement dynamic forms in Angular, focusing on real-world scenarios.

First, let’s understand what makes a form “dynamic”:

  1. Form fields are generated based on configuration/data
  2. Validation rules can be added/modified at runtime
  3. Form structure can change based on user input
  4. Fields can be conditionally shown/hidden

Here’s how I would implement it:

// 1. Define the form configuration interface
interface FormField {
  type: 'text' | 'number' | 'select' | 'checkbox';
  name: string;
  label: string;
  validations?: {
    required?: boolean;
    minLength?: number;
    pattern?: string;
    custom?: (value: any) => boolean;
  };
  options?: { value: any; label: string; }[];
  dependent?: {
    field: string;
    value: any;
    action: 'show' | 'hide';
  };
}

// 2. Create a dynamic form component
@Component({
  selector: 'app-dynamic-form',
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      @for (field of formFields; track field.name) {
        @if (shouldShowField(field)) {
          <div class="form-field">
            <label>{{ field.label }}</label>
            
            @switch (field.type) {
              @case ('text') {
                <input 
                  [formControlName]="field.name"
                  [type]="field.type"
                />
              }
              @case ('select') {
                <select [formControlName]="field.name">
                  @for (option of field.options; track option.value) {
                    <option [value]="option.value">
                      {{ option.label }}
                    </option>
                  }
                </select>
              }
              @case ('checkbox') {
                <input 
                  type="checkbox"
                  [formControlName]="field.name"
                />
              }
            }
            
            @if (showError(field.name)) {
              <div class="error">
                {{ getErrorMessage(field.name) }}
              </div>
            }
          </div>
        }
      }
      <button type="submit">Submit</button>
    </form>
  `
})
export class DynamicFormComponent {
  @Input() formFields: FormField[] = [];
  form!: FormGroup;
  
  ngOnInit() {
    this.createForm();
    this.setupFieldDependencies();
  }
  
  private createForm() {
    const group = {};
    
    for (const field of this.formFields) {
      group[field.name] = ['', this.getValidators(field)];
    }
    
    this.form = new FormGroup(group);
  }
  
  private getValidators(field: FormField): ValidatorFn[] {
    const validators: ValidatorFn[] = [];
    
    if (field.validations?.required) {
      validators.push(Validators.required);
    }
    
    if (field.validations?.minLength) {
      validators.push(
        Validators.minLength(field.validations.minLength)
      );
    }
    
    if (field.validations?.pattern) {
      validators.push(
        Validators.pattern(field.validations.pattern)
      );
    }
    
    if (field.validations?.custom) {
      validators.push((control) => {
        return field.validations?.custom?.(control.value) 
          ? null 
          : { custom: true };
      });
    }
    
    return validators;
  }
  
  private setupFieldDependencies() {
    for (const field of this.formFields) {
      if (field.dependent) {
        this.form.get(field.dependent.field)?.valueChanges
          .pipe(takeUntilDestroyed())
          .subscribe(value => {
            this.handleDependency(field, value);
          });
      }
    }
  }
  
  private handleDependency(field: FormField, value: any) {
    const shouldShow = value === field.dependent?.value;
    const control = this.form.get(field.name);
    
    if (field.dependent?.action === 'show') {
      if (shouldShow) {
        control?.enable();
      } else {
        control?.disable();
      }
    }
  }
  
  shouldShowField(field: FormField): boolean {
    if (!field.dependent) return true;
    
    const dependentValue = this.form.get(
      field.dependent.field
    )?.value;
    
    return field.dependent.action === 'show'
      ? dependentValue === field.dependent.value
      : dependentValue !== field.dependent.value;
  }
  
  showError(fieldName: string): boolean {
    const control = this.form.get(fieldName);
    return control?.invalid && control?.touched || false;
  }
  
  getErrorMessage(fieldName: string): string {
    const control = this.form.get(fieldName);
    
    if (!control?.errors) return '';
    
    if (control.errors['required']) {
      return 'This field is required';
    }
    
    if (control.errors['minlength']) {
      return 'Input is too short';
    }
    
    if (control.errors['pattern']) {
      return 'Invalid format';
    }
    
    if (control.errors['custom']) {
      return 'Invalid value';
    }
    
    return 'Invalid input';
  }
  
  onSubmit() {
    if (this.form.valid) {
      console.log(this.form.value);
    } else {
      this.markFormTouched();
    }
  }
  
  private markFormTouched() {
    Object.values(this.form.controls).forEach(
      control => control.markAsTouched()
    );
  }
}

Now, here’s how you would use this dynamic form:

// In your parent component
const formConfig: FormField[] = [
  {
    type: 'select',
    name: 'userType',
    label: 'User Type',
    options: [
      { value: 'individual', label: 'Individual' },
      { value: 'company', label: 'Company' }
    ],
    validations: {
      required: true
    }
  },
  {
    type: 'text',
    name: 'companyName',
    label: 'Company Name',
    dependent: {
      field: 'userType',
      value: 'company',
      action: 'show'
    },
    validations: {
      required: true,
      minLength: 3
    }
  },
  {
    type: 'text',
    name: 'email',
    label: 'Email',
    validations: {
      required: true,
      pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$'
    }
  },
  {
    type: 'checkbox',
    name: 'subscribe',
    label: 'Subscribe to newsletter'
  }
];

@Component({
  template: `
    <app-dynamic-form
      [formFields]="formConfig"
      (formSubmit)="handleSubmit($event)"
    />
  `
})
export class ParentComponent {
  formConfig = formConfig;
  
  handleSubmit(data: any) {
    // Handle form submission
  }
}

Key points to emphasize in the interview:

  1. Flexibility: The solution can handle various field types and can be easily extended.

  2. Validation: Supports both built-in and custom validators that can be dynamically applied.

  3. Dependencies: Fields can be shown/hidden based on other field values.

  4. Type Safety: Using TypeScript interfaces ensures type safety throughout the implementation.

  5. Performance: Uses Angular’s latest control flow syntax (@if, @for) for better performance.

  6. Maintainability: The code is modular and follows Angular best practices.

  7. Error Handling: Comprehensive error handling and display.

  8. Reusability: The component can be reused across the application with different configurations.

This implementation provides a robust foundation for dynamic forms while remaining flexible enough to handle various use cases. The code follows Angular best practices and modern patterns, making it both maintainable and performant.

Test Your Knowledge

Take a quick quiz to test your understanding of this topic.