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 thevalue
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).
-
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 storedfn
with the new value. This notifies the AngularFormControl
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.
-
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
fn
reference. You should call this storedfn
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 itstouched
state 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
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 thedisabled
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() === '');
}
}
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
CustomComponent
and Angular'sReactiveFormsModule
,FormGroup
,UntypedFormBuilder
, andValidators
. - Injecting
UntypedFormBuilder
to create yourFormGroup
. - Initializing the
form
with aFormControl
(e.g.,customFormControl
) that uses your custom component. You can set an initialvalue
anddisabled
state directly when defining the control, and yourControlValueAccessor
will 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 thesetDisabledState
method on yourCustomComponent
. - You might also have methods to programmatically update the value of the custom component, for instance,
updateCustomControl()
. WhensetValue()
is called on theFormControl
, thewriteValue
method of yourCustomComponent
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 aFormControl
usingformControlName="yourFormControlName"
. This directive is what tells Angular to link your custom component to a specific control within yourFormGroup
, allowing Angular to automatically handle theControlValueAccessor
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 theform.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, showingwriteValue
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());
}
}
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)