DEV Community

Cover image for Build Custom Components for Angular Reactive Forms with ControlValueAccessor
Kalyan P C
Kalyan P C

Posted on

Build Custom Components for Angular Reactive Forms with ControlValueAccessor

Introduction

Angular's reactive forms are incredibly powerful, providing a robust way to manage form input and validation. However, the true flexibility of Angular forms shines when you need to go beyond standard HTML input elements and create your own custom form controls. This is where the ControlValueAccessor interface becomes an indispensable tool, acting as the crucial bridge between your custom component and Angular's sophisticated form machinery.

In this post, we will focus entirely on creating our reusable custom component from the ground up in Angular 20. You'll learn how to implement ControlValueAccessor to enable seamless two-way data binding, making your custom component fully prepared for integration with Angular's reactive forms, and how to dynamically enable and disable it.

Why Custom Components for Forms?

You might be asking, "Why bother with a custom component when native HTML inputs or other form elements already exist?" Here are a few compelling reasons:

  • Encapsulation of Complex Logic: Your custom component might have specific formatting, validation rules, or even a unique UI that involves multiple native HTML elements. Encapsulation keeps your templates clean.
  • Reusability: Build it once, use it everywhere. A well-designed custom form component can be used across different forms and even different projects.
  • Theming and Styling: Centralize your component's styling and behavior in one place.
  • Accessibility: Implement specific ARIA attributes and keyboard interactions consistently.
  • Integration with Third-Party Libraries: If you're wrapping a third-party UI component as a form control.

Prerequisites

To follow along, make sure you have:

  • Node.js and npm/Yarn installed.
  • Angular CLI installed (version 18+ is recommended, but Angular 20 is used in this example).

Project Setup

Let's start by setting up a new Angular project and generating our components.

ng new custom-component-demo
cd custom-component-demo
ng generate component custom-component
ng generate component form-component
Enter fullscreen mode Exit fullscreen mode

What is ControlValueAccessor

Before we dive into the code, let's understand what ControlValueAccessor is and why it's essential for building custom form controls in Angular.

The ControlValueAccessor interface serves as a crucial bridge between an Angular FormControl instance and a native DOM element, or in our case, a completely custom component. It defines a set of methods that allow Angular's powerful forms module to read and write values to your custom control, as well as listen for changes and manage its touched and disabled states. By implementing this interface, your custom component can seamlessly integrate into reactive forms, behaving just like any standard <input> or <select> element.

To make your custom component compatible with Angular forms, you must not only implement the ControlValueAccessor interface but also register it with Angular using the NG_VALUE_ACCESSOR token in your component's providers array.

You'll typically see this structure in your custom component's @Component decorator:

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

@Component({
  selector: 'app-custom-component',
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: CustomComponent, // Reference to your custom component class
      multi: true // Essential for allowing multiple accessors
    }
  ]
})
export class CustomComponent implements ControlValueAccessor { ... }

Enter fullscreen mode Exit fullscreen mode

To make your custom component compatible with Angular forms, you must implement the ControlValueAccessor interface, which requires defining the following four methods:

  1. writeValue(obj: any): void

    • Purpose: This method is called by the Angular Forms API to write a new value from the form model into your custom component's view.
    • Implementation: Inside this method, you should take the obj (the new value) and use it to update the internal state or property of your component that represents its current value. For example, if your custom component wraps an <input> element, you would set the value property of that input.
    • When it's called: This method is invoked when the form control's value changes programmatically (e.g., formControl.setValue(), formControl.patchValue(), or when the form is initialized with a value).
  2. registerOnChange(fn: any): void

    • Purpose: This method is called by the Angular Forms API to register a callback function (fn) that should be invoked whenever the value of your custom component changes in the UI.
    • Implementation: You must store this fn reference. Whenever the internal value of your custom component changes (e.g., a user types into an internal input, or interacts with a custom button that changes the value), you must call this stored fn with the new value. This notifies the Angular FormControl that its bound value has been updated from the view.
    • When it's called: Angular calls this method once during the form control's initialization.
  3. registerOnTouched(fn: any): void

    • Purpose: Similar to registerOnChange, this method registers a callback function (fn) that should be invoked when your custom component receives a "touch" event.
    • Implementation: You must store this fn reference. You should call this stored fn when the custom component loses focus (e.g., the user blurs out of an internal input). This signals to Angular that the control has been interacted with, setting its touched state to true.
    • When it's called: Angular calls this method once during the form control's initialization.
  4. setDisabledState(isDisabled: boolean): void

    • Purpose: This method is called by the Angular Forms API to update the disabled state of your custom component.
    • Implementation: You should take the isDisabled boolean value and use it to enable or disable the interactive elements within your custom component's template. For instance, you might add or remove the disabled attribute on an internal <input> or change its styling.
    • When it's called: This method is invoked when the form control's disabled state changes programmatically (e.g., formControl.disable(), formControl.enable()), or when the control is initialized with a disabled state.

By correctly implementing these four methods, your custom component will behave as a first-class citizen within Angular's reactive forms ecosystem.

Implementing CustomComponent with ControlValueAccessor

The ControlValueAccessor interface acts as a bridge between an Angular FormControl instance and a native DOM element (or a custom element). It allows Angular's forms module to read and write values to your custom control, as well as listen for changes.

Let's dive into the code for custom-component.component.ts:

custom-component.component.ts

import { Component, computed, input, model, signal } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({...})
export class CustomComponent implements ControlValueAccessor {
  // Functions to be called when the control's value changes or it's touched
  onChangeFn: any = () => {};
  onTouchedFn: any = () => {};

  // Input for externally set disabled state (e.g., via [disabled] attribute)
  readonly disabled = input<boolean>(false);
  // Signal for disabled state managed by the form (via setDisabledState)
  readonly formDisabled = signal<boolean>(false);
  // Computed signal that combines both disabled states
  readonly isDisabled = computed(() => this.disabled() || this.formDisabled());

  // Model signal for the component's value, enabling two-way binding
  readonly value = model<string>('');
  // Input for the HTML id attribute
  readonly id = input<string>('');
  // Signal to indicate an error state for styling
  readonly error = signal<boolean>(false);

  /**
   * Writes a new value from the form model into the view (and updates the custom component).
   * @param obj The value to be written.
   */
  writeValue(obj: any): void {
    console.log('writeValue called with:', obj);
    this.value.set(obj);
  }

  /**
   * Registers a function to be called when the control's value changes in the UI.
   * This function should be called by the custom control whenever its value changes.
   * @param fn The function to register.
   */
  registerOnChange(fn: any): void {
    this.onChangeFn = fn;
  }

  /**
   * Registers a function to be called when the control receives a touch event.
   * This function should be called by the custom control when it is "touched" (e.g., blurs).
   * @param fn The function to register.
   */
  registerOnTouched(fn: any): void {
    this.onTouchedFn = fn;
  }

  /**
   * Called by the Forms API to update the control's disabled state.
   * @param isDisabled True if the control should be disabled, false otherwise.
   */
  setDisabledState(isDisabled: boolean): void {
    this.formDisabled.set(isDisabled);
  }

  /**
   * Handles the input event from the native HTML input element (or internal logic).
   * Updates the component's internal value, calls the registered onChangeFn,
   * marks the control as touched, and sets an error state if the input is empty.
   * @param event The input event.
   */
  onChange(event: Event): void {
    console.log('Input changed:', event);
    const inputElement = event.target as HTMLInputElement; // Assuming an input for simplicity
    this.value.set(inputElement.value);
    this.onChangeFn(inputElement.value);
    this.onTouchedFn();
    this.error.set(inputElement.value.trim() === '');
  }
}

Enter fullscreen mode Exit fullscreen mode

custom-component.html

<input 
  type="text"
  class="custom-input"
  [value]="value()"
  [id]="id()"
  [disabled]="isDisabled()"
  (input)="onChange($event)"
  [class.error]="error()"
  [class.success]="!error()"/>
Enter fullscreen mode Exit fullscreen mode

Using CustomComponent in a Reactive Form

Now let's see how to integrate our CustomComponent into an Angular reactive form.

You will typically have a main form component (FormComponentComponent in our example) that manages the FormGroup and contains instances of your CustomComponent.

The TypeScript file for your form component will involve:

  • Importing your CustomComponent and Angular's ReactiveFormsModule, FormGroup, UntypedFormBuilder, and Validators.
  • Injecting UntypedFormBuilder to create your FormGroup.
  • Initializing the form with a FormControl (e.g., customFormControl) that uses your custom component. You can set an initial value and disabled state directly when defining the control, and your ControlValueAccessor will handle it.
  • Implementing methods to interact with the form control, such as disableControl() and enableControl() to manage the disabled state. When these methods are called on the FormControl (e.g., this.form.get('customFormControl')?.disable()), Angular's Forms API will invoke the setDisabledState method on your CustomComponent.
  • You might also have methods to programmatically update the value of the custom component, for instance, updateCustomControl(). When setValue() is called on the FormControl, the writeValue method of your CustomComponent is triggered.

In the HTML template of your form component:

  • You will bind your FormGroup to the <form> element using [formGroup]="form".
  • You will use your custom component's selector (e.g., <app-custom-component>) and associate it with a FormControl using formControlName="yourFormControlName". This directive is what tells Angular to link your custom component to a specific control within your FormGroup, allowing Angular to automatically handle the ControlValueAccessor methods behind the scenes.
  • You can also pass inputs to your custom component, like an id for accessibility, using standard Angular property binding ([id]="id()").
  • Buttons or other UI elements can be used to trigger methods on your form component that manipulate the state of the form controls, such as enabling or disabling them.
  • You can display the form.status, touched, dirty states, and the form.value to observe the reactive form's behavior.
  • An example of programmatically updating the custom component's value can be demonstrated by having another input whose changes setValue() on the custom component's control, showing writeValue in action.
import { Component, inject, signal } from '@angular/core';
import { CustomComponent } from './custom-component/custom-component.component';
import { FormGroup, ReactiveFormsModule, UntypedFormBuilder, Validators } from '@angular/forms';
import { JsonPipe } from '@angular/common';

@Component({
  selector: 'app-form-component',
  imports: [CustomComponent, JsonPipe, ReactiveFormsModule],
  template: `
    <form [formGroup]="form">
        <label [for]="id()">My custom form component</label>
        <app-custom-component formControlName="customFormControl" [id]="id()"/>
        <button type="submit">Submit</button>
    </form>

    <button type="button" (click)="disableControl()">Disable Component</button>
    <button type="button" (click)="enableControl()">Enable Component</button>

    <p>Form status: {{ form.status }}</p>
    <p>Custom Component touched: {{ form.get('customFormControl')?.touched }}</p>
    <p>Custom Component dirty: {{ form.get('customFormControl')?.dirty }}</p>
    <p>Form value: {{ form.value | json }}</p>

    <label for="test-input">Write Value to custom component</label>
    <input type="text" name="test-input" id="test-input" (input)="updateCustomControl($event)"/>
  `, 
  styleUrl: './form-component.scss'
})
export class FormComponentComponent {
  fb = inject(UntypedFormBuilder);
  id = signal<string>('custom-form-control');
  inputValue = signal<string>('Initial Value');
  form: FormGroup;

  constructor() {
    this.form = this.fb.group({
      customFormControl: this.fb.control({value: this.inputValue(), disabled: false}, Validators.required)
    });
  }

  disableControl() {
    this.form.get('customFormControl')?.disable();
  }

  enableControl() {
    this.form.get('customFormControl')?.enable();
  }

  updateCustomControl(event: Event) {
    const inputElement = event.target as HTMLInputElement;
    this.inputValue.set(inputElement.value);
    this.form.get('customFormControl')?.setValue(this.inputValue());
  }
}
Enter fullscreen mode Exit fullscreen mode

Live Demo on StackBlitz

To see the complete working example of the custom component integrated into a reactive form, you can check out this StackBlitz demo:

Angular Custom Form Component Demo on StackBlitz


Conclusion

Building custom form controls with ControlValueAccessor is a fundamental skill for any Angular developer working with complex forms. It allows you to create highly reusable, encapsulated, and powerful components that integrate seamlessly with Angular's reactive forms module.

By following this guide, you've learned how to:

  • Implement the ControlValueAccessor interface for a custom component.
  • Bridge your custom component's value and disabled state with Angular forms.
  • Register your custom component as a form control using NG_VALUE_ACCESSOR.
  • Integrate your custom component into a reactive form using formControlName.
  • Dynamically enable and disable your custom component via the FormControl.

This knowledge opens up a world of possibilities for creating sophisticated and user-friendly forms in your Angular applications. Happy coding!

Top comments (0)