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:
- Configuration-Driven Design: Define form structure through JSON config
- Dynamic Field Rendering: Render different field types based on configuration
- Form State Management: Handle validation and data across multiple tabs
- 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:
- Configuration defines tabs and fields
- DynamicFieldHostComponent renders appropriate field component
- BaseFormFieldComponent handles validation and state
- 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:
- Form Persistence: Save form data to localStorage
- Progress Bar: Visual indicator of completion
- Field Dependencies: Show/hide fields based on other field values
- Custom Validators: Complex validation rules
- File Upload: Add file upload field type
- Form Templates: Pre-defined form configurations
- Accessibility: ARIA labels and keyboard navigation
Interview Tips
- Start with Configuration: Define your data structure first
- Explain Architecture: Talk through your component design decisions
- Handle Edge Cases: Consider validation, navigation, and error states
- Show Extensibility: Demonstrate how to add new field types
- Focus on UX: Think about user experience and accessibility
- 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!
Frontend Machine Coding #1: Build a Mini Snakes and Ladders Game with React
Learn how to build a complete Snakes and Ladders game using React with step-by-step explanation and code examples
Todo Application with Advanced Features
Master machine coding by creating a feature-rich Todo application with filtering, persistence, real-time updates, and modern React patterns.