Free discovery callFree discovery call

Building Custom Form Controls in Angular

Learn how to create reusable and accessible custom form controls using Control Value Accessors.

DevelopmentLast updated: 16 Dec 20244 min read

By Matej Jakšić

Custom form controls are essential in Angular applications. From fancy date pickers and dynamic sliders to star rating components, Control Value Accessors (CVAs) enable seamless integration with Angular's forms API.

In this guide, we'll examine Control Value Accessors, their use cases, and demonstrate how to build a reusable Star Rating Component using Angular's standalone component architecture.


What Are Control Value Accessors?

Control Value Accessors (CVAs) are a fundamental part of Angular's forms ecosystem. They enable custom form components to integrate with Angular's form APIs (FormControl, FormGroup, ngModel) — working just like native form elements such as <input> and <select>.

Control Value Accessors enable you to:

  • Bind values from a form to a custom component
  • React to changes in the component's value
  • Support Angular's validation and state tracking

When to Use Control Value Accessors

You should implement a Control Value Accessor when you need:

  1. Custom Form Components: Such as sliders, star ratings, dropdown selectors, etc.
  2. Form Integration: Your component needs to work with formControlName or ngModel
  3. Complex UI Logic: Your component handles asynchronous data or manages multiple values

When Not to Use Control Value Accessors

If your component doesn't need form integration or won't use Angular's forms API, stick with a simple @Input() and @Output() approach. Implementing a Control Value Accessor when it's not needed only adds unnecessary complexity to your component.

Benefits of Using formControlName with CVAs

Using formControlName with CVAs offers:

  • Seamless Integration: Automatic value synchronization, validation, and state tracking
  • Clean API: Simply use formControlName for direct binding instead of passing FormControl through @Input()
  • Validation Support: Access Angular's built-in and custom validators effortlessly
  • Reusable Components: Create standardized interfaces that work consistently across your application

Building a Star Rating Component with CVA (Standalone)

Let's build a star rating component that works seamlessly with Angular's reactive forms using Control Value Accessors. For simplicity, the template will be inline.

Step 1: Create the Star Rating Component

We'll start by creating a standalone component for the star rating. It will:

  1. Render 5 clickable stars.
  2. Allow the user to select a rating.
  3. Work seamlessly with Angular forms using formControlName.
import { Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-star-rating',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="stars" role="radiogroup" aria-labelledby="star-rating-label">
      <ng-container *ngFor="let star of stars; let i = index">
        <i
          class="star"
          [attr.aria-checked]="i < value ? 'true' : 'false'"
          [class.filled]="i < value"
          (click)="onStarClick(i)"
        >
          ★
        </i>
      </ng-container>
    </div>
  `,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => StarRatingComponent),
      multi: true,
    },
  ],
  styleUrls: ['./star-rating.component.scss'],
})
export class StarRatingComponent implements ControlValueAccessor {
  value = 0;
  stars = Array(5).fill(false);

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

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

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

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

  onStarClick(index: number): void {
    this.value = index + 1;
    this.onChange(this.value);
    this.onTouched();
  }
}

Step 2: Create the Rating Form

Next, we’ll create a standalone form component to demonstrate how the star rating component works with Angular’s reactive forms.

import { Component } from '@angular/core';
import {
  FormBuilder,
  FormGroup,
  Validators,
  ReactiveFormsModule,
} from '@angular/forms';
import { CommonModule } from '@angular/common';
import { StarRatingComponent } from '../star-rating/star-rating.component';

@Component({
  selector: 'app-rating-form',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule, StarRatingComponent],
  template: `
     <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <label for="rating" id="star-rating-label">Rate your experience</label>
      <app-star-rating formControlName="rating"></app-star-rating>

      <!-- Error message handling -->
      <div
        *ngIf="ratingControl?.touched && ratingControl?.invalid"
        class="error-message"
        role="alert"
      >
        {{ errorMessage }}
      </div>

      <div class="form-buttons">
        <button type="submit" [disabled]="form.invalid">Submit</button>
        <button type="button" (click)="reset()">Reset</button>
      </div>
    </form>
  `,
  styleUrls: ['./rating-form.component.scss'],
})
export class RatingFormComponent {
  form: FormGroup;

  constructor(private fb: FormBuilder) {
    this.form = this.fb.group({
      rating: [0, [Validators.required, Validators.min(1)]], // Default initial value
    });
  }

  // Get the rating control once to avoid repetition
  get ratingControl() {
    return this.form.get('rating');
  }

  // Get the error message based on the validation
  get errorMessage(): string {
    const control = this.ratingControl;
    if (control?.hasError('required')) return 'Please select a rating.';
    if (control?.hasError('min')) return 'Rating must be at least 1.';
    return '';
  }

  // Handle form submission
  onSubmit(): void {
    if (this.form.valid) {
      console.log('Form Submitted with Rating:', this.form.value);
    }
  }

  // Handle form reset and trigger error message
  reset(): void {
    this.form.reset();
    this.ratingControl?.markAsTouched();
  }
}

Finally, after adding some styling, app looks like this:

Default state
Valid state
Error state

Key Takeaways

  1. Seamless Forms Integration: CVA lets you build custom controls that feel native to Angular's forms ecosystem.
  2. Simplified Reusability: Using formControlName makes custom controls straightforward to implement and maintain.
  3. Encapsulation of Logic: CVAs encapsulate the internal behavior of your custom controls, keeping the API clean for parent components.

Final Thoughts

Control Value Accessors are a powerful feature in Angular, especially when paired with standalone components. They make custom form controls reusable, maintainable, and fully compatible with Angular’s forms API. If your app relies heavily on forms, mastering CVAs is a must for creating a seamless user experience.

Happy coding! 🎉

You can find the whole source code in the Github repo.

Thank you for your the time to read this blog! Feel free to share your thoughts about this topic and drop us an email at hello@prototyp.digital.

Related ArticlesTechnology x Design

View all articlesView all articles
( 01 )Get started

Start a Project