Frontend Machine Coding #2: Build a Dynamic Tab Form Component with Angular

Learn how to build a configurable tabbed form component using Angular with reactive forms, standalone components, and dynamic field rendering

Frontend Machine Coding #2: Build a Dynamic Tab Form Component with Angular

Problem Statement

Build a dynamic tabbed form component that can handle multiple form sections across different tabs. This is a common UI pattern used in real-world applications for complex forms like user registration, settings pages, or multi-step wizards.

Requirements

Must-Have Features:

  • Multiple tabs, each containing separate form sections
  • Configurable form fields (text, textarea, checkbox, etc.)
  • Form validation for each field
  • Navigation between tabs with validation
  • Reusable and scalable architecture
  • Submit all form data at the end

Nice-to-Have Features:

  • Progress indicator showing completion status
  • Tab-specific validation before allowing navigation
  • Dynamic field rendering based on configuration
  • Responsive design for mobile devices
  • Form state persistence across tab switches

🧠 Understanding the Problem

What is a Tab Form Component?

A tabbed form component is a UI pattern that:

  • Splits a long form into logical sections across multiple tabs
  • Validates each section before allowing navigation
  • Collects all data and submits everything at once
  • Provides a better user experience for complex forms

Key Concepts to Implement:

  1. Configuration-Driven Design: Define form structure through JSON config
  2. Dynamic Field Rendering: Render different field types based on configuration
  3. Form State Management: Handle validation and data across multiple tabs
  4. Component Architecture: Create reusable, extensible components

🏗️ Solution Architecture

Component Structure:

App
├── TabFormComponent (Main container)
├── TabNavigationComponent (Tab headers)
├── DynamicFieldHostComponent (Field renderer)
├── BaseFormFieldComponent (Shared logic)
├── CustomInputComponent (Text fields)
├── CustomTextareaComponent (Textarea fields)
└── CustomCheckboxComponent (Checkbox fields)

Data Flow:

  1. Configuration defines tabs and fields
  2. DynamicFieldHostComponent renders appropriate field component
  3. BaseFormFieldComponent handles validation and state
  4. TabFormComponent manages overall form state and navigation

Complete Solution

Step 1: Define Interfaces and Types

First, let's define the structure for our configuration-driven approach:

// Field configuration interfaces
export interface Tabs {
  label: string;
  fields: Array<FieldConfig>;
}

export interface FieldConfig {
  name: string;
  type: 'text' | 'textarea' | 'checkbox' | 'settings';
  options?: FieldOptions;
}

export interface FieldOptions {
  label?: string;
  placeholder?: string;
  validators?: Array<ValidatorFn>;
  validationMessages?: Array<ErrorMessage>;
  value?: any;
  disabled?: boolean;
  onChange?: any;
}

export interface ErrorMessage {
  name: string;
  message: string;
}

// Validation error names enum
export enum ValidationErrorNames {
  required = 'required',
  email = 'email',
  minlength = 'minlength',
  maxlength = 'maxlength',
  pattern = 'pattern'
}

Step 2: Create the Base Field Component

This abstract class provides shared functionality for all form fields:

import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { FormControl, FormGroup, ValidatorFn } from '@angular/forms';
import { Subscription } from 'rxjs';

export abstract class BaseFormFieldComponent implements OnInit, OnDestroy {
  @Input() config!: FieldConfig;
  @Input() form!: FormGroup;
  
  private controlSubscription?: Subscription;

  // Get the form control for this field
  get control(): FormControl {
    return this.form.controls[this.config.name] as FormControl;
  }

  // Get error message for validation
  get errorMessage(): string | null {
    if (!this.control || this.control.valid || !this.control.touched) {
      return null;
    }

    const errors = this.control.errors || {};
    for (const errorName of Object.keys(errors)) {
      const matchingMessage = this.config.options?.validationMessages?.find(
        (msg) => msg.name === errorName
      );
      return matchingMessage?.message || 'Invalid field';
    }
    return null;
  }

  ngOnInit(): void {
    this.registerControlListener();
  }

  // Subscribe to value changes
  protected registerControlListener(): void {
    this.controlSubscription = this.control.valueChanges.subscribe((value) => {
      this.onControlValueChange(value);
    });
  }

  // Override this method in child components for custom logic
  protected onControlValueChange(value: any): void {}

  ngOnDestroy(): void {
    this.controlSubscription?.unsubscribe();
  }
}

Step 3: Create Custom Field Components

Now let's create specific field components that extend the base class:

// Text Input Component
@Component({
  selector: 'custom-input',
  imports: [CommonModule, ReactiveFormsModule],
  standalone: true,
  template: `
    <div class="field-container">
      <label class="field-label">{{ config.options?.label }}</label>
      <input 
        [formControl]="control" 
        [placeholder]="config.options?.placeholder"
        type="text" 
        class="field-input"
        [disabled]="config.options?.disabled"
      />
      <span *ngIf="errorMessage" class="error-message">
        {{ errorMessage }}
      </span>
    </div>
  `,
  styles: [`
    .field-container {
      margin-bottom: 15px;
    }
    .field-label {
      display: block;
      margin-bottom: 5px;
      font-weight: 500;
    }
    .field-input {
      width: 100%;
      padding: 8px 12px;
      border: 1px solid #ddd;
      border-radius: 4px;
      font-size: 14px;
    }
    .field-input:focus {
      outline: none;
      border-color: #007bff;
      box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
    }
    .error-message {
      color: #dc3545;
      font-size: 12px;
      margin-top: 4px;
      display: block;
    }
  `]
})
export class CustomInputComponent extends BaseFormFieldComponent {}

// Textarea Component
@Component({
  selector: 'custom-textarea',
  imports: [CommonModule, ReactiveFormsModule],
  standalone: true,
  template: `
    <div class="field-container">
      <label class="field-label">{{ config.options?.label }}</label>
      <textarea 
        [formControl]="control" 
        [placeholder]="config.options?.placeholder"
        class="field-textarea"
        rows="4"
        [disabled]="config.options?.disabled"
      ></textarea>
      <span *ngIf="errorMessage" class="error-message">
        {{ errorMessage }}
      </span>
    </div>
  `,
  styles: [`
    .field-textarea {
      width: 100%;
      padding: 8px 12px;
      border: 1px solid #ddd;
      border-radius: 4px;
      font-size: 14px;
      resize: vertical;
      min-height: 80px;
    }
    .field-textarea:focus {
      outline: none;
      border-color: #007bff;
      box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
    }
  `]
})
export class CustomTextareaComponent extends BaseFormFieldComponent {}

// Checkbox Component
@Component({
  selector: 'custom-checkbox',
  imports: [CommonModule, ReactiveFormsModule],
  standalone: true,
  template: `
    <div class="field-container">
      <label class="checkbox-label">
        <input 
          type="checkbox" 
          [formControl]="control"
          [disabled]="config.options?.disabled"
          class="checkbox-input"
        />
        <span class="checkbox-text">{{ config.options?.label }}</span>
      </label>
      <span *ngIf="errorMessage" class="error-message">
        {{ errorMessage }}
      </span>
    </div>
  `,
  styles: [`
    .checkbox-label {
      display: flex;
      align-items: center;
      cursor: pointer;
    }
    .checkbox-input {
      margin-right: 8px;
    }
    .checkbox-text {
      font-size: 14px;
    }
  `]
})
export class CustomCheckboxComponent extends BaseFormFieldComponent {}

Step 4: Create Dynamic Field Host Component

This component dynamically renders the appropriate field component based on the configuration:

import { Component, Input, OnInit, ViewChild, ViewContainerRef, ComponentRef } from '@angular/core';
import { FormGroup } from '@angular/forms';

// Map field types to component classes
const FIELD_TYPE_COMPONENT_MAP: { [key: string]: any } = {
  'text': CustomInputComponent,
  'textarea': CustomTextareaComponent,
  'checkbox': CustomCheckboxComponent,
  'settings': CustomInputComponent // Default fallback
};

@Component({
  selector: 'dynamic-field-host',
  standalone: true,
  template: `<ng-container #container></ng-container>`
})
export class DynamicFieldHostComponent implements OnInit {
  @Input() field!: FieldConfig;
  @Input() form!: FormGroup;

  @ViewChild('container', { read: ViewContainerRef, static: true })
  container!: ViewContainerRef;

  private componentRef: ComponentRef<any> | null = null;

  ngOnInit() {
    const componentType = FIELD_TYPE_COMPONENT_MAP[this.field.type];
    if (!componentType) {
      console.warn(`Unknown field type: ${this.field.type}`);
      return;
    }

    // Create component dynamically
    this.componentRef = this.container.createComponent(componentType);
    this.componentRef.instance.config = this.field;
    this.componentRef.instance.form = this.form;
  }
}

Step 5: Create Tab Navigation Component

This component handles tab switching and validation:

import { Component, Input, OnInit, Output, EventEmitter } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'tab-navigation',
  imports: [CommonModule],
  standalone: true,
  template: `
    <div class="tab-navigation">
      <div 
        *ngFor="let tab of tabs; let i = index" 
        class="tab-item"
        [class.active]="currentTabIndex === i"
        [class.completed]="isTabCompleted(i)"
        (click)="switchTab(i)"
      >
        <span class="tab-number">{{ i + 1 }}</span>
        <span class="tab-label">{{ tab.label }}</span>
        <span *ngIf="isTabCompleted(i)" class="tab-check">✓</span>
      </div>
    </div>
  `,
  styles: [`
    .tab-navigation {
      display: flex;
      border-bottom: 2px solid #e9ecef;
      margin-bottom: 20px;
    }
    .tab-item {
      flex: 1;
      padding: 15px 20px;
      text-align: center;
      cursor: pointer;
      border-bottom: 3px solid transparent;
      transition: all 0.3s ease;
      position: relative;
    }
    .tab-item:hover {
      background-color: #f8f9fa;
    }
    .tab-item.active {
      border-bottom-color: #007bff;
      color: #007bff;
    }
    .tab-item.completed {
      color: #28a745;
    }
    .tab-number {
      display: inline-block;
      width: 24px;
      height: 24px;
      border-radius: 50%;
      background: #e9ecef;
      color: #6c757d;
      line-height: 24px;
      margin-right: 8px;
      font-size: 12px;
      font-weight: bold;
    }
    .tab-item.active .tab-number {
      background: #007bff;
      color: white;
    }
    .tab-item.completed .tab-number {
      background: #28a745;
      color: white;
    }
    .tab-check {
      margin-left: 8px;
      color: #28a745;
      font-weight: bold;
    }
  `]
})
export class TabNavigationComponent implements OnInit {
  @Input() tabs: Tabs[] = [];
  @Input() currentTabIndex: number = 0;
  @Input() form!: FormGroup;
  @Output() tabChange = new EventEmitter<number>();

  ngOnInit() {
    // Initial validation check on component initialization
    this.validateCurrentTab();
  }

  switchTab(index: number) {
    if (this.isTabValid(index)) {
      this.currentTabIndex = index;
      this.validateCurrentTab(); // Re-validate after switching
    } else {
      alert('Please complete the current tab before proceeding.');
    }
  }

  isTabCompleted(index: number): boolean {
    const tab = this.tabs[index];
    return tab.fields.every(field => {
      const control = this.form.get(field.name);
      return control && control.valid && control.value !== '';
    });
  }

  isTabValid(index: number): boolean {
    const tab = this.tabs[index];
    return tab.fields.every(field => {
      const control = this.form.get(field.name);
      return control && control.valid;
    });
  }

  validateCurrentTab() {
    const currentTab = this.tabs[this.currentTabIndex];
    if (!this.isTabValid(this.currentTabIndex)) {
      this.tabChange.emit(this.currentTabIndex); // Prevent navigation
    }
  }
}

Step 6: Main Tab Form Component

This is the main component that orchestrates everything:

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'tab-form',
  imports: [
    CommonModule, 
    ReactiveFormsModule, 
    TabNavigationComponent, 
    DynamicFieldHostComponent
  ],
  standalone: true,
  template: `
    <div class="tab-form-container">
      <h2 class="form-title">Multi-Step Form</h2>
      
      <tab-navigation
        [tabs]="tablist"
        [currentTabIndex]="currentTabIndex"
        [form]="form"
        (tabChange)="onTabChange($event)"
      ></tab-navigation>

      <div class="tab-content">
        <div class="tab-header">
          <h3>{{ tablist[currentTabIndex].label }}</h3>
          <div class="progress-indicator">
            Step {{ currentTabIndex + 1 }} of {{ tablist.length }}
          </div>
        </div>

        <form [formGroup]="form" (ngSubmit)="onSubmit()">
          <div class="fields-container">
            <dynamic-field-host
              *ngFor="let field of tablist[currentTabIndex].fields"
              [field]="field"
              [form]="form"
            ></dynamic-field-host>
          </div>

          <div class="form-actions">
            <button 
              type="button" 
              class="btn btn-secondary"
              (click)="previousTab()"
              [disabled]="currentTabIndex === 0"
            >
              Previous
            </button>
            
            <button 
              type="button" 
              class="btn btn-primary"
              (click)="nextTab()"
              *ngIf="currentTabIndex < tablist.length - 1"
            >
              Next
            </button>
            
            <button 
              type="submit" 
              class="btn btn-success"
              *ngIf="currentTabIndex === tablist.length - 1"
            >
              Submit
            </button>
          </div>
        </form>
      </div>
    </div>
  `,
  styles: [`
    .tab-form-container {
      max-width: 800px;
      margin: 0 auto;
      padding: 20px;
      background: white;
      border-radius: 8px;
      box-shadow: 0 2px 10px rgba(0,0,0,0.1);
    }
    .form-title {
      text-align: center;
      margin-bottom: 30px;
      color: #333;
    }
    .tab-content {
      padding: 20px 0;
    }
    .tab-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 20px;
      padding-bottom: 10px;
      border-bottom: 1px solid #e9ecef;
    }
    .progress-indicator {
      color: #6c757d;
      font-size: 14px;
    }
    .fields-container {
      margin-bottom: 30px;
    }
    .form-actions {
      display: flex;
      justify-content: space-between;
      gap: 10px;
    }
    .btn {
      padding: 10px 20px;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      font-size: 14px;
      transition: background-color 0.3s ease;
    }
    .btn:disabled {
      opacity: 0.6;
      cursor: not-allowed;
    }
    .btn-primary {
      background: #007bff;
      color: white;
    }
    .btn-primary:hover:not(:disabled) {
      background: #0056b3;
    }
    .btn-secondary {
      background: #6c757d;
      color: white;
    }
    .btn-secondary:hover:not(:disabled) {
      background: #545b62;
    }
    .btn-success {
      background: #28a745;
      color: white;
    }
    .btn-success:hover:not(:disabled) {
      background: #1e7e34;
    }
  `]
})
export class TabFormComponent implements OnInit {
  form!: FormGroup;
  currentTabIndex = 0;

  // Sample configuration - in real apps, this would come from API or props
  tablist: Tabs[] = [
    {
      label: 'Profile',
      fields: [
        {
          name: 'name',
          type: 'text',
          options: {
            label: 'Full Name',
            placeholder: 'Enter your full name',
            value: '',
            validators: [Validators.required, Validators.minLength(3)],
            validationMessages: [
              { name: ValidationErrorNames.required, message: 'Name is required' },
              { name: ValidationErrorNames.minlength, message: 'Name must be at least 3 characters' }
            ]
          }
        },
        {
          name: 'email',
          type: 'text',
          options: {
            label: 'Email Address',
            placeholder: 'Enter your email address',
            value: '',
            validators: [Validators.required, Validators.email],
            validationMessages: [
              { name: ValidationErrorNames.required, message: 'Email is required' },
              { name: ValidationErrorNames.email, message: 'Please enter a valid email address' }
            ]
          }
        }
      ]
    },
    {
      label: 'Preferences',
      fields: [
        {
          name: 'bio',
          type: 'textarea',
          options: {
            label: 'Bio',
            placeholder: 'Tell us about yourself...',
            value: '',
            validators: [Validators.maxLength(500)],
            validationMessages: [
              { name: ValidationErrorNames.maxlength, message: 'Bio cannot exceed 500 characters' }
            ]
          }
        },
        {
          name: 'newsletter',
          type: 'checkbox',
          options: {
            label: 'Subscribe to newsletter',
            value: false
          }
        }
      ]
    },
    {
      label: 'Settings',
      fields: [
        {
          name: 'theme',
          type: 'text',
          options: {
            label: 'Theme Preference',
            placeholder: 'light or dark',
            value: 'light',
            validators: [Validators.required],
            validationMessages: [
              { name: ValidationErrorNames.required, message: 'Theme preference is required' }
            ]
          }
        }
      ]
    }
  ];

  constructor(private fb: FormBuilder) {}

  ngOnInit() {
    this.createForm();
  }

  createForm() {
    const group: { [key: string]: any } = {};
    
    this.tablist.forEach(tab => {
      tab.fields.forEach(field => {
        group[field.name] = [
          field.options?.value || '',
          field.options?.validators || []
        ];
      });
    });

    this.form = this.fb.group(group);
  }

  onTabChange(index: number) {
    this.currentTabIndex = index;
  }

  nextTab() {
    if (this.isCurrentTabValid()) {
      this.currentTabIndex++;
    } else {
      this.markCurrentTabAsTouched();
      alert('Please complete all required fields before proceeding.');
    }
  }

  previousTab() {
    this.currentTabIndex--;
  }

  isCurrentTabValid(): boolean {
    const currentTab = this.tablist[this.currentTabIndex];
    return currentTab.fields.every(field => {
      const control = this.form.get(field.name);
      return control && control.valid;
    });
  }

  markCurrentTabAsTouched() {
    const currentTab = this.tablist[this.currentTabIndex];
    currentTab.fields.forEach(field => {
      const control = this.form.get(field.name);
      if (control) {
        control.markAsTouched();
      }
    });
  }

  onSubmit() {
    if (this.form.valid) {
      console.log('Form submitted:', this.form.value);
      alert('Form submitted successfully!');
    } else {
      this.markCurrentTabAsTouched();
      alert('Please complete all required fields.');
    }
  }
}

Step 7: Complete Implementation with App Component

// app.component.ts
import { Component } from '@angular/core';
import { TabFormComponent } from './tab-form.component';

@Component({
  selector: 'app-root',
  imports: [TabFormComponent],
  standalone: true,
  template: `
    <div class="app-container">
      <tab-form></tab-form>
    </div>
  `,
  styles: [`
    .app-container {
      min-height: 100vh;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      padding: 20px;
    }
  `]
})
export class AppComponent {}

Key Learning Points

1. Configuration-Driven Architecture

  • Flexibility: Easy to add new tabs and fields without code changes
  • Maintainability: Form structure defined in JSON, not hardcoded
  • Scalability: Can handle complex forms with many fields

2. Dynamic Component Rendering

  • ViewContainerRef: Angular's way to dynamically create components
  • ComponentFactory: Pattern for instantiating components at runtime
  • Type Safety: Using TypeScript interfaces for configuration

3. Form Management

  • Reactive Forms: Angular's powerful form handling
  • Validation: Custom validators and error messages
  • State Management: Tracking form state across multiple tabs

4. Component Design Patterns

  • Base Class Pattern: Shared functionality through inheritance
  • Composition: Breaking complex UI into smaller, focused components
  • Single Responsibility: Each component has one clear purpose

Advanced Features (Stretch Goals)

If you finish early, consider adding:

  1. Form Persistence: Save form data to localStorage
  2. Progress Bar: Visual indicator of completion
  3. Field Dependencies: Show/hide fields based on other field values
  4. Custom Validators: Complex validation rules
  5. File Upload: Add file upload field type
  6. Form Templates: Pre-defined form configurations
  7. Accessibility: ARIA labels and keyboard navigation

Interview Tips

  1. Start with Configuration: Define your data structure first
  2. Explain Architecture: Talk through your component design decisions
  3. Handle Edge Cases: Consider validation, navigation, and error states
  4. Show Extensibility: Demonstrate how to add new field types
  5. Focus on UX: Think about user experience and accessibility
  6. Test Your Logic: Verify form validation and submission work correctly

🎉 Conclusion

This Tab Form Component implementation demonstrates:

  • Angular Fundamentals: Reactive forms, standalone components, dependency injection
  • Dynamic Rendering: Runtime component creation and configuration
  • Architecture Patterns: Base classes, composition, and configuration-driven design
  • User Experience: Multi-step forms with validation and progress tracking

The key is building a flexible, maintainable system that can handle complex form requirements while providing a great user experience. This approach shows interviewers your ability to think architecturally and build scalable solutions.

Remember: Focus on clean code, proper separation of concerns, and creating a solution that can evolve with changing requirements!