BLOG POSTS
Angular Custom Form Control: How to Build Your Own

Angular Custom Form Control: How to Build Your Own

Angular custom form controls allow developers to create reusable, encapsulated form components that integrate seamlessly with Angular’s reactive forms system. This capability is essential when building complex UI elements like date pickers, autocomplete inputs, or multi-select dropdowns that need to behave like native form controls. You’ll learn how to implement the ControlValueAccessor interface, handle validation states, and create form controls that work with FormBuilder, template-driven forms, and all Angular form features out of the box.

How Angular Custom Form Controls Work

Angular’s form system relies on the ControlValueAccessor interface to bridge the gap between DOM elements and the framework’s form control abstractions. When you create a custom form control, you’re essentially building a component that can communicate bidirectionally with Angular’s FormControl instances.

The ControlValueAccessor interface requires implementing four key methods:

  • writeValue() – Updates the component when the form control value changes
  • registerOnChange() – Registers a callback function to notify Angular of value changes
  • registerOnTouched() – Registers a callback to notify when the control is touched
  • setDisabledState() – Handles the disabled state of the control

Here’s the basic structure every custom form control needs:

import { Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'app-custom-input',
  template: `<input [value]="value" (input)="onInput($event)" (blur)="onTouched()">`,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => CustomInputComponent),
      multi: true
    }
  ]
})
export class CustomInputComponent implements ControlValueAccessor {
  value: string = '';
  
  private onChange = (value: string) => {};
  private onTouched = () => {};

  writeValue(value: string): void {
    this.value = value || '';
  }

  registerOnChange(fn: (value: string) => void): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }

  setDisabledState(isDisabled: boolean): void {
    // Handle disabled state
  }

  onInput(event: Event): void {
    const input = event.target as HTMLInputElement;
    this.value = input.value;
    this.onChange(this.value);
  }
}

Step-by-Step Implementation Guide

Let’s build a practical example: a rating component that works with Angular forms. This component will display clickable stars and integrate with form validation.

Step 1: Create the Component Structure

import { Component, forwardRef, Input } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR, NG_VALIDATORS, AbstractControl, ValidationErrors, Validator } from '@angular/forms';

@Component({
  selector: 'app-star-rating',
  template: `
    <div class="star-rating" [class.disabled]="disabled">
      <span 
        *ngFor="let star of stars; let i = index"
        class="star"
        [class.filled]="i < rating"
        [class.hoverable]="!disabled"
        (click)="setRating(i + 1)"
        (mouseenter)="onHover(i + 1)"
        (mouseleave)="onHover(0)">
        β˜…
      </span>
      <span class="rating-text" *ngIf="showText">{{rating}}/{{maxRating}}</span>
    </div>
  `,
  styleUrls: ['./star-rating.component.css'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => StarRatingComponent),
      multi: true
    },
    {
      provide: NG_VALIDATORS,
      useExisting: forwardRef(() => StarRatingComponent),
      multi: true
    }
  ]
})
export class StarRatingComponent implements ControlValueAccessor, Validator {
  @Input() maxRating: number = 5;
  @Input() showText: boolean = false;
  @Input() required: boolean = false;

  rating: number = 0;
  hoverRating: number = 0;
  disabled: boolean = false;
  stars: number[] = [];

  private onChange = (value: number) => {};
  private onTouched = () => {};

  ngOnInit(): void {
    this.stars = Array(this.maxRating).fill(0).map((_, i) => i + 1);
  }
}

Step 2: Implement ControlValueAccessor Methods

  writeValue(value: number): void {
    this.rating = value || 0;
  }

  registerOnChange(fn: (value: number) => void): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }

  setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }

  setRating(rating: number): void {
    if (this.disabled) return;
    
    this.rating = rating;
    this.onChange(this.rating);
    this.onTouched();
  }

  onHover(rating: number): void {
    if (this.disabled) return;
    this.hoverRating = rating;
  }

Step 3: Add Custom Validation

  validate(control: AbstractControl): ValidationErrors | null {
    if (this.required && (!control.value || control.value === 0)) {
      return { required: true };
    }
    
    if (control.value && (control.value < 1 || control.value > this.maxRating)) {
      return { range: { min: 1, max: this.maxRating, actual: control.value } };
    }
    
    return null;
  }

Step 4: Add Component Styles

.star-rating {
  display: inline-flex;
  align-items: center;
  gap: 2px;
}

.star {
  font-size: 24px;
  color: #ddd;
  cursor: pointer;
  transition: color 0.2s ease;
  user-select: none;
}

.star.filled {
  color: #ffd700;
}

.star.hoverable:hover {
  color: #ffed4e;
}

.star-rating.disabled .star {
  cursor: default;
  opacity: 0.6;
}

.rating-text {
  margin-left: 8px;
  font-size: 14px;
  color: #666;
}

Real-World Examples and Use Cases

Here’s how to use the custom star rating component in different scenarios:

Reactive Forms Integration

// In your component
export class ReviewFormComponent {
  reviewForm = this.fb.group({
    title: ['', Validators.required],
    rating: [0, [Validators.required, Validators.min(1)]],
    comment: ['']
  });

  constructor(private fb: FormBuilder) {}

  onSubmit(): void {
    if (this.reviewForm.valid) {
      console.log('Form values:', this.reviewForm.value);
      // { title: 'Great product', rating: 4, comment: 'Really liked it' }
    }
  }
}

// In your template
<form [formGroup]="reviewForm" (ngSubmit)="onSubmit()">
  <input formControlName="title" placeholder="Review title">
  
  <app-star-rating 
    formControlName="rating"
    [maxRating]="5"
    [showText]="true"
    [required]="true">
  </app-star-rating>
  
  <textarea formControlName="comment" placeholder="Your review"></textarea>
  
  <button type="submit" [disabled]="reviewForm.invalid">Submit Review</button>
</form>

Template-Driven Forms

<form #reviewForm="ngForm">
  <app-star-rating 
    [(ngModel)]="rating"
    name="rating"
    #ratingControl="ngModel"
    [required]="true"
    [maxRating]="10">
  </app-star-rating>
  
  <div *ngIf="ratingControl.invalid && ratingControl.touched" class="error">
    Rating is required
  </div>
</form>

Advanced Multi-Select Component Example

@Component({
  selector: 'app-multi-select',
  template: `
    <div class="multi-select" [class.open]="isOpen">
      <div class="selected-items" (click)="toggleDropdown()">
        <span *ngIf="selectedItems.length === 0" class="placeholder">{{placeholder}}</span>
        <div *ngFor="let item of selectedItems" class="selected-item">
          {{item.label}}
          <span class="remove" (click)="removeItem($event, item)">Γ—</span>
        </div>
      </div>
      
      <div *ngIf="isOpen" class="dropdown">
        <input 
          #searchInput
          [(ngModel)]="searchTerm"
          (input)="onSearch()"
          placeholder="Search..."
          class="search-input">
          
        <div class="options">
          <div 
            *ngFor="let option of filteredOptions"
            class="option"
            [class.selected]="isSelected(option)"
            (click)="toggleOption(option)">
            <input 
              type="checkbox"
              [checked]="isSelected(option)"
              (click)="$event.stopPropagation()">
            {{option.label}}
          </div>
        </div>
      </div>
    </div>
  `,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => MultiSelectComponent),
      multi: true
    }
  ]
})
export class MultiSelectComponent implements ControlValueAccessor, OnInit {
  @Input() options: SelectOption[] = [];
  @Input() placeholder: string = 'Select items...';
  @Input() maxSelections?: number;

  selectedItems: SelectOption[] = [];
  filteredOptions: SelectOption[] = [];
  searchTerm: string = '';
  isOpen: boolean = false;

  private onChange = (value: any[]) => {};
  private onTouched = () => {};

  ngOnInit(): void {
    this.filteredOptions = [...this.options];
  }

  writeValue(value: any[]): void {
    if (value && Array.isArray(value)) {
      this.selectedItems = this.options.filter(option => 
        value.some(val => val === option.value || val.value === option.value)
      );
    } else {
      this.selectedItems = [];
    }
  }

  registerOnChange(fn: (value: any[]) => void): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }

  toggleOption(option: SelectOption): void {
    if (this.isSelected(option)) {
      this.removeItem(null, option);
    } else if (!this.maxSelections || this.selectedItems.length < this.maxSelections) {
      this.selectedItems.push(option);
      this.emitChange();
    }
  }

  removeItem(event: Event | null, item: SelectOption): void {
    if (event) {
      event.stopPropagation();
    }
    
    this.selectedItems = this.selectedItems.filter(selected => selected.value !== item.value);
    this.emitChange();
  }

  private emitChange(): void {
    const values = this.selectedItems.map(item => item.value);
    this.onChange(values);
    this.onTouched();
  }

  isSelected(option: SelectOption): boolean {
    return this.selectedItems.some(item => item.value === option.value);
  }

  onSearch(): void {
    this.filteredOptions = this.options.filter(option =>
      option.label.toLowerCase().includes(this.searchTerm.toLowerCase())
    );
  }

  toggleDropdown(): void {
    this.isOpen = !this.isOpen;
    if (!this.isOpen) {
      this.onTouched();
    }
  }
}

interface SelectOption {
  value: any;
  label: string;
}

Performance Considerations and Best Practices

When building custom form controls, performance and user experience are crucial factors. Here are key optimization strategies:

Aspect Best Practice Performance Impact Implementation
Change Detection Use OnPush strategy Reduces unnecessary re-renders by ~70% changeDetection: ChangeDetectionStrategy.OnPush
Event Handling Debounce rapid changes Prevents excessive API calls debounceTime(300) for search inputs
Large Datasets Virtual scrolling Handles 10k+ items efficiently CDK Virtual Scrolling
Memory Leaks Proper subscription cleanup Prevents memory growth Unsubscribe in ngOnDestroy

Optimized Multi-Select with Performance Enhancements

@Component({
  selector: 'app-optimized-multi-select',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <cdk-virtual-scroll-viewport itemSize="40" class="dropdown" *ngIf="isOpen">
      <div 
        *cdkVirtualFor="let option of filteredOptions$ | async"
        class="option"
        [class.selected]="isSelected(option)"
        (click)="toggleOption(option)">
        {{option.label}}
      </div>
    </cdk-virtual-scroll-viewport>
  `
})
export class OptimizedMultiSelectComponent implements ControlValueAccessor, OnInit, OnDestroy {
  private destroy$ = new Subject<void>();
  private searchSubject = new Subject<string>();
  
  filteredOptions$ = this.searchSubject.pipe(
    debounceTime(300),
    distinctUntilChanged(),
    map(term => this.filterOptions(term)),
    takeUntil(this.destroy$)
  );

  ngOnInit(): void {
    this.searchSubject.next('');
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }

  onSearch(term: string): void {
    this.searchSubject.next(term);
  }

  private filterOptions(searchTerm: string): SelectOption[] {
    if (!searchTerm.trim()) {
      return this.options;
    }
    
    return this.options.filter(option =>
      option.label.toLowerCase().includes(searchTerm.toLowerCase())
    );
  }

  // Track by function for *ngFor optimization
  trackByOption(index: number, option: SelectOption): any {
    return option.value;
  }
}

Common Pitfalls and Troubleshooting

Issue 1: Form Control Not Updating

The most common problem occurs when the onChange callback isn’t called properly:

// Wrong - forgetting to call onChange
onInputChange(event: Event): void {
  const input = event.target as HTMLInputElement;
  this.value = input.value;
  // Missing: this.onChange(this.value);
}

// Correct
onInputChange(event: Event): void {
  const input = event.target as HTMLInputElement;
  this.value = input.value;
  this.onChange(this.value); // Essential for form integration
}

Issue 2: Validation Not Working

Ensure you’re providing the NG_VALIDATORS token and implementing the Validator interface:

// Missing validator provider
providers: [
  {
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => CustomComponent),
    multi: true
  }
  // Add this:
  // {
  //   provide: NG_VALIDATORS,
  //   useExisting: forwardRef(() => CustomComponent),
  //   multi: true
  // }
]

Issue 3: Circular Dependency Errors

Always use forwardRef when providing your component:

// Wrong
useExisting: CustomComponent

// Correct
useExisting: forwardRef(() => CustomComponent)

Issue 4: Performance Problems with Large Lists

// Optimization techniques for large datasets
@Component({
  template: `
    <!-- Use trackBy for better performance -->
    <div *ngFor="let item of items; trackBy: trackByFn">
      {{item.name}}
    </div>
    
    <!-- For very large lists, use virtual scrolling -->
    <cdk-virtual-scroll-viewport itemSize="50" class="viewport">
      <div *cdkVirtualFor="let item of items">{{item.name}}</div>
    </cdk-virtual-scroll-viewport>
  `
})
export class LargeListComponent {
  trackByFn(index: number, item: any): any {
    return item.id; // Use unique identifier
  }
}

Integration with Angular Material and Third-Party Libraries

Custom form controls can leverage Angular Material’s form field infrastructure for consistent styling and behavior:

import { MatFormFieldControl } from '@angular/material/form-field';
import { Subject } from 'rxjs';
import { coerceBooleanProperty } from '@angular/cdk/coercion';

@Component({
  selector: 'app-material-custom-input',
  template: `
    <input 
      [id]="id"
      [placeholder]="placeholder"
      [disabled]="disabled"
      [value]="value"
      (input)="onInput($event)"
      (blur)="onFocusOut()"
      (focus)="onFocusIn()">
  `,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => MaterialCustomInputComponent),
      multi: true
    },
    {
      provide: MatFormFieldControl,
      useExisting: MaterialCustomInputComponent
    }
  ]
})
export class MaterialCustomInputComponent 
  implements ControlValueAccessor, MatFormFieldControl<string>, OnDestroy {
  
  static nextId = 0;
  
  stateChanges = new Subject<void>();
  id = `custom-input-${MaterialCustomInputComponent.nextId++}`;
  controlType = 'custom-input';
  autofilled?: boolean = false;

  private _placeholder: string = '';
  private _required: boolean = false;
  private _disabled: boolean = false;
  
  @Input()
  get placeholder(): string {
    return this._placeholder;
  }
  set placeholder(value: string) {
    this._placeholder = value;
    this.stateChanges.next();
  }

  @Input()
  get required(): boolean {
    return this._required;
  }
  set required(value: boolean) {
    this._required = coerceBooleanProperty(value);
    this.stateChanges.next();
  }

  get empty(): boolean {
    return !this.value;
  }

  get shouldLabelFloat(): boolean {
    return this.focused || !this.empty;
  }

  // Usage in template
  // <mat-form-field>
  //   <mat-label>Custom Input</mat-label>
  //   <app-material-custom-input formControlName="customField"></app-material-custom-input>
  //   <mat-error *ngIf="form.get('customField')?.hasError('required')">
  //     This field is required
  //   </mat-error>
  // </mat-form-field>
}

For comprehensive documentation on Angular forms and custom controls, check the official Angular forms guide and the ControlValueAccessor API reference.

Custom form controls unlock powerful possibilities for creating reusable, accessible, and performant form components. Whether you’re building simple input wrappers or complex multi-step wizards, following these patterns ensures your components integrate seamlessly with Angular’s form ecosystem while maintaining excellent user experience and developer ergonomics.



This article incorporates information and material from various online sources. We acknowledge and appreciate the work of all original authors, publishers, and websites. While every effort has been made to appropriately credit the source material, any unintentional oversight or omission does not constitute a copyright infringement. All trademarks, logos, and images mentioned are the property of their respective owners. If you believe that any content used in this article infringes upon your copyright, please contact us immediately for review and prompt action.

This article is intended for informational and educational purposes only and does not infringe on the rights of the copyright owners. If any copyrighted material has been used without proper credit or in violation of copyright laws, it is unintentional and we will rectify it promptly upon notification. Please note that the republishing, redistribution, or reproduction of part or all of the contents in any form is prohibited without express written permission from the author and website owner. For permissions or further inquiries, please contact us.

Leave a reply

Your email address will not be published. Required fields are marked