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
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 { ... }
To make your custom component compatible with Angular forms, you must implement the ControlValueAccessor interface, which requires defining the following four methods:
-
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 thevalueproperty 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).
-
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
fnreference. 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 storedfnwith the new value. This notifies the AngularFormControlthat its bound value has been updated from the view. - When it's called: Angular calls this method once during the form control's initialization.
-
Purpose: This method is called by the Angular Forms API to register a callback function (
-
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
fnreference. You should call this storedfnwhen 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 itstouchedstate totrue. - When it's called: Angular calls this method once during the form control's initialization.
-
Purpose: Similar to
-
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
isDisabledboolean value and use it to enable or disable the interactive elements within your custom component's template. For instance, you might add or remove thedisabledattribute 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() === '');
}
}
custom-component.html
<input
type="text"
class="custom-input"
[value]="value()"
[id]="id()"
[disabled]="isDisabled()"
(input)="onChange($event)"
[class.error]="error()"
[class.success]="!error()"/>
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
CustomComponentand Angular'sReactiveFormsModule,FormGroup,UntypedFormBuilder, andValidators. - Injecting
UntypedFormBuilderto create yourFormGroup. - Initializing the
formwith aFormControl(e.g.,customFormControl) that uses your custom component. You can set an initialvalueanddisabledstate directly when defining the control, and yourControlValueAccessorwill handle it. - Implementing methods to interact with the form control, such as
disableControl()andenableControl()to manage the disabled state. When these methods are called on theFormControl(e.g.,this.form.get('customFormControl')?.disable()), Angular's Forms API will invoke thesetDisabledStatemethod on yourCustomComponent. - You might also have methods to programmatically update the value of the custom component, for instance,
updateCustomControl(). WhensetValue()is called on theFormControl, thewriteValuemethod of yourCustomComponentis triggered.
In the HTML template of your form component:
- You will bind your
FormGroupto the<form>element using[formGroup]="form". - You will use your custom component's selector (e.g.,
<app-custom-component>) and associate it with aFormControlusingformControlName="yourFormControlName". This directive is what tells Angular to link your custom component to a specific control within yourFormGroup, allowing Angular to automatically handle theControlValueAccessormethods behind the scenes. - You can also pass inputs to your custom component, like an
idfor 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,dirtystates, and theform.valueto 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, showingwriteValuein 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());
}
}
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
ControlValueAccessorinterface 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)