Introduction: Beyond Native Inputs
Welcome back, coding adventurer! In our previous chapters, you’ve become a wizard with Angular Reactive Forms, mastering FormGroup, FormControl, and various built-in validators. You’ve built dynamic forms, handled complex validation, and even started thinking about conditional logic. That’s fantastic!
But what happens when you need a form input that isn’t a simple <input type="text"> or <select>? What if you want to create a fancy star rating component, a custom rich text editor, or a complex date picker that behaves just like a native form control, complete with validation, disabled states, and seamless integration with your FormGroup? This is where Angular’s powerful ControlValueAccessor interface comes into play!
In this chapter, we’re going to unlock the secret to building truly reusable, custom form controls. You’ll learn what ControlValueAccessor is, why it’s a game-changer for complex UI, and how to implement it step-by-step to create your own custom inputs that play perfectly with Angular Reactive Forms. Get ready to level up your form-building skills!
Prerequisites
Before we dive in, make sure you’re comfortable with:
- Creating Angular projects and components.
- Working with
FormGroupandFormControl. - Applying built-in validators like
Validators.required. - Basic understanding of
@Input()and@Output()decorators.
Let’s build something awesome!
Core Concepts: The ControlValueAccessor Superpower
Imagine you have a beautifully designed star rating component. It uses icons, has hover effects, and allows users to click to select a rating. Now, you want to use this component within your Angular form, just like you would use a regular <input> field. You want to bind it to a FormControl, validate its value, and get its value when the form submits. How do you make your custom component “talk” to Angular’s forms system?
That’s precisely the problem ControlValueAccessor solves!
What is ControlValueAccessor? (The “Bridge” Analogy)
Think of ControlValueAccessor as a bridge or an adapter that allows your custom component to communicate with the Angular Forms API. It’s an interface that defines a standard way for a component to:
- Receive values from the
FormControl(e.g., when you setformControl.setValue(3)). - Report changes back to the
FormControl(e.g., when a user clicks a star, your component tells the form, “Hey, my value just changed to 4!”). - Report “touched” state to the
FormControl(e.g., when a user interacts with your component and then moves away, your component tells the form, “I’ve been touched!”). - Receive disabled state from the
FormControl(e.g., when you callformControl.disable()).
Without this bridge, your custom component would be an isolated island, unable to participate in the powerful Angular Forms ecosystem.
Why Do We Need ControlValueAccessor?
- Encapsulation & Reusability: Build complex UI widgets once and reuse them across multiple forms and projects. The internal implementation details are hidden from the parent form.
- Seamless Integration: Your custom component behaves exactly like a native HTML input. You can use
formControlName,[formControl], apply validators, and manage its state (valid, dirty, touched, disabled) directly through theFormControl. - Abstraction: The parent form doesn’t need to know how your star rating component works internally; it just needs to know it provides a value and responds to changes.
- Consistency: Ensures all your form controls, whether native or custom, adhere to the same API for interaction with Angular Forms.
Key Methods of the ControlValueAccessor Interface
The ControlValueAccessor interface, part of @angular/forms, requires you to implement a few key methods:
writeValue(obj: any): void- Purpose: This method is called by the Angular forms API whenever the
FormControlassociated with this custom control has its value updated programmatically (e.g.,formControl.setValue(5)orformControl.patchValue(...)). - Your Job: Take the
obj(the new value) and update your component’s internal state and UI to reflect this value.
- Purpose: This method is called by the Angular forms API whenever the
registerOnChange(fn: any): void- Purpose: This method is called by the Angular forms API to register a callback function.
- Your Job: Store this
fnfunction. Whenever your custom component’s internal value changes (e.g., a user clicks a star), you must call this storedfnwith the new value. This notifies theFormControlthat its bound value has changed.
registerOnTouched(fn: any): void- Purpose: Similar to
registerOnChange, this method registers another callback function. - Your Job: Store this
fnfunction. Whenever your custom component is “touched” (e.g., a user interacts with it and then blurs away), you must call this storedfn. This notifies theFormControlthat it has been touched, which is crucial for validation messages (ng-touched).
- Purpose: Similar to
setDisabledState?(isDisabled: boolean): void(Optional)- Purpose: This method is called when the
FormControlassociated with your component is programmatically enabled or disabled (e.g.,formControl.disable()orformControl.enable()). - Your Job: Take the
isDisabledboolean and update your component’s UI and internal logic to reflect the disabled state. For instance, you might make the stars unclickable or change their color.
- Purpose: This method is called when the
The NG_VALUE_ACCESSOR Token
How does Angular know that your component is a ControlValueAccessor? You tell it by providing your component using the NG_VALUE_ACCESSOR injection token in your component’s providers array.
// Inside your @Component decorator's providers array
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => StarRatingComponent), // Your component class
multi: true // Essential for registering multiple CVA's if needed, but always for custom controls
}
]
provide: NG_VALUE_ACCESSOR: This is the injection token Angular looks for.useExisting: forwardRef(() => StarRatingComponent): This tells Angular to use an existing instance ofStarRatingComponentas theControlValueAccessor.forwardRefis necessary becauseStarRatingComponentisn’t fully defined yet when theprovidersarray is evaluated, preventing a circular dependency.multi: true: This is crucial. It tells Angular thatNG_VALUE_ACCESSORcan have multiple providers (e.g., if you had multiple custom controls in different places). Withoutmulti: true, your custom control would override any otherControlValueAccessorregistered in the application.
Phew! That’s a lot of theory, but understanding these concepts makes the implementation much clearer. Ready to build our star rating component? Let’s go!
Step-by-Step Implementation: Building a Star Rating Component
We’ll create a simple star rating component that displays 5 stars. Users can click on a star to set a rating, and this rating will be seamlessly integrated into our FormGroup.
Step 1: Set Up Your Angular Project
If you don’t have an Angular project ready, let’s quickly create one. We’ll use Angular CLI version 18 (as of 2025-12-05).
# Check Angular CLI version (should be 18.x.x)
ng version
# If you need to update:
# npm uninstall -g @angular/cli
# npm cache clean --force
# npm install -g @angular/cli@latest
# Create a new project (using NgModule for this guide for simplicity)
ng new custom-cva-app --no-standalone --routing false --style css --skip-tests
cd custom-cva-app
Now, let’s generate our custom star rating component:
ng generate component star-rating
Next, we need to ensure our app.module.ts imports ReactiveFormsModule so we can use Reactive Forms.
Open src/app/app.module.ts:
// src/app/app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { ReactiveFormsModule } from '@angular/forms'; // <-- Import this!
import { AppComponent } from './app.component';
import { StarRatingComponent } from './star-rating/star-rating.component';
@NgModule({
declarations: [
AppComponent,
StarRatingComponent
],
imports: [
BrowserModule,
ReactiveFormsModule // <-- Add to imports array!
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Great! Our project is set up.
Step 2: Design the StarRating Component Template and Basic Logic
First, let’s make our StarRatingComponent display some stars and manage its internal rating.
Open src/app/star-rating/star-rating.component.ts:
// src/app/star-rating/star-rating.component.ts
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-star-rating',
templateUrl: './star-rating.component.html',
styleUrls: ['./star-rating.component.css']
})
export class StarRatingComponent {
// We'll add this later for the ControlValueAccessor
// onChange = (value: any) => {};
// onTouched = () => {};
@Input() maxStars: number = 5; // How many stars to display
rating: number = 0; // The currently selected rating
hoverRating: number = 0; // For visual hover effect
// Generate an array of numbers from 1 to maxStars for *ngFor
get stars(): number[] {
return Array(this.maxStars).fill(0).map((x, i) => i + 1);
}
// When a star is clicked
rate(star: number): void {
this.rating = star;
// We will call onChange here later
// this.onChange(this.rating);
// this.onTouched(); // Also call onTouched
}
// When mouse enters a star
hover(star: number): void {
this.hoverRating = star;
}
// When mouse leaves the star area
leave(): void {
this.hoverRating = 0;
}
// Helper to determine if a star should be "filled" (active)
isStarActive(star: number): boolean {
return star <= (this.hoverRating || this.rating);
}
}
Now, let’s create the template for our stars.
Open src/app/star-rating/star-rating.component.html:
<!-- src/app/star-rating/star-rating.component.html -->
<div class="star-rating-container"
(mouseleave)="leave()">
<span *ngFor="let star of stars"
class="star"
[class.active]="isStarActive(star)"
(click)="rate(star)"
(mouseenter)="hover(star)">
★ <!-- Unicode star character -->
</span>
</div>
And some basic styling to make them look like stars.
Open src/app/star-rating/star-rating.component.css:
/* src/app/star-rating/star-rating.component.css */
.star-rating-container {
display: inline-block;
font-size: 2em; /* Make stars larger */
cursor: pointer;
}
.star {
color: #ccc; /* Default grey */
transition: color 0.1s ease-in-out;
}
.star.active {
color: gold; /* Active stars are gold */
}
/* Optional: Add a disabled style later */
.star-rating-container.disabled .star {
color: #eee;
cursor: not-allowed;
}
.star-rating-container.disabled .star.active {
color: #ddd;
}
At this point, you can add <app-star-rating></app-star-rating> to app.component.html and run ng serve to see your stars. They’ll be interactive, but they won’t communicate with any form yet.
Step 3: Implement ControlValueAccessor Interface
This is where the magic happens! We’ll make our StarRatingComponent conform to the ControlValueAccessor interface.
Open src/app/star-rating/star-rating.component.ts again and update it:
// src/app/star-rating/star-rating.component.ts
import { Component, Input, forwardRef } from '@angular/core'; // <-- Import forwardRef
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; // <-- Import these!
@Component({
selector: 'app-star-rating',
templateUrl: './star-rating.component.html',
styleUrls: ['./star-rating.component.css'],
// This is the crucial part: tell Angular this component is a ControlValueAccessor
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => StarRatingComponent), // Reference to this component
multi: true // Essential for custom form controls
}
]
})
// Implement the ControlValueAccessor interface
export class StarRatingComponent implements ControlValueAccessor { // <-- Implement CVA
@Input() maxStars: number = 5;
rating: number = 0;
hoverRating: number = 0;
isDisabled: boolean = false; // Internal state for disabled
// These are placeholders for the functions Angular will provide
// We'll call them when our internal value changes or the control is touched
private onChange = (value: any) => {};
private onTouched = () => {};
get stars(): number[] {
return Array(this.maxStars).fill(0).map((x, i) => i + 1);
}
rate(star: number): void {
if (this.isDisabled) { // Prevent rating if disabled
return;
}
this.rating = star;
this.onChange(this.rating); // <-- Notify Angular Forms that the value changed!
this.onTouched(); // <-- Notify Angular Forms that the control was touched!
}
hover(star: number): void {
if (this.isDisabled) {
return;
}
this.hoverRating = star;
}
leave(): void {
if (this.isDisabled) {
return;
}
this.hoverRating = 0;
}
isStarActive(star: number): boolean {
return star <= (this.hoverRating || this.rating);
}
// --- ControlValueAccessor methods ---
// Called by the forms API to write a value to the component
writeValue(obj: any): void {
// Make sure obj is a valid number, otherwise default to 0
if (typeof obj === 'number' && obj >= 0 && obj <= this.maxStars) {
this.rating = obj;
} else {
this.rating = 0; // Default or handle invalid input
}
}
// Called by the forms API to register a callback function
// Your component should call this function whenever its value changes
registerOnChange(fn: any): void {
this.onChange = fn;
}
// Called by the forms API to register a callback function
// Your component should call this function whenever it's "touched" (e.g., on blur)
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
// Optional: Called by the forms API to set the disabled state
setDisabledState?(isDisabled: boolean): void {
this.isDisabled = isDisabled;
// You might want to update the UI to reflect the disabled state
// We'll add a class to the host element for this.
}
}
Now, let’s modify the star-rating.component.html and star-rating.component.css to visually reflect the disabled state.
Update src/app/star-rating/star-rating.component.html:
<!-- src/app/star-rating/star-rating.component.html -->
<!-- Use ngClass to add 'disabled' class if isDisabled is true -->
<div class="star-rating-container"
[class.disabled]="isDisabled"
(mouseleave)="leave()">
<span *ngFor="let star of stars"
class="star"
[class.active]="isStarActive(star)"
(click)="rate(star)"
(mouseenter)="hover(star)">
★
</span>
</div>
The CSS we added earlier will now take effect when isDisabled is true.
Step 4: Integrate StarRating into AppComponent
Now that our StarRatingComponent is a proper ControlValueAccessor, we can use it just like any other form control!
Open src/app/app.component.ts:
// src/app/app.component.ts
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; // <-- Import FormBuilder & Validators
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
title = 'Custom Control Value Accessor';
feedbackForm!: FormGroup; // Use definite assignment assertion
constructor(private fb: FormBuilder) {}
ngOnInit(): void {
this.feedbackForm = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
// Our custom star rating control!
rating: [0, [Validators.required, Validators.min(1)]], // Default to 0, but require at least 1 star
comments: ['']
});
// Optional: Watch for value changes in the form
this.feedbackForm.valueChanges.subscribe(value => {
console.log('Form Value Changed:', value);
});
}
onSubmit(): void {
if (this.feedbackForm.valid) {
console.log('Form Submitted!', this.feedbackForm.value);
alert('Form Submitted! Check console for data.');
} else {
console.log('Form is invalid. Please check fields.');
alert('Please fill out all required fields correctly.');
// Mark all fields as touched to display validation messages
this.feedbackForm.markAllAsTouched();
}
}
// Example of programmatically setting a value
setRatingTo(value: number): void {
this.feedbackForm.get('rating')?.setValue(value);
}
// Example of programmatically disabling/enabling
toggleRatingDisabled(): void {
const ratingControl = this.feedbackForm.get('rating');
if (ratingControl?.disabled) {
ratingControl.enable();
} else {
ratingControl?.disable();
}
}
}
Now, let’s update src/app/app.component.html to include our form and the app-star-rating component.
<!-- src/app/app.component.html -->
<div class="container">
<h1>{{ title }}</h1>
<form [formGroup]="feedbackForm" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="name">Your Name:</label>
<input type="text" id="name" formControlName="name" class="form-control">
<div *ngIf="feedbackForm.get('name')?.invalid && (feedbackForm.get('name')?.dirty || feedbackForm.get('name')?.touched)"
class="alert alert-danger">
Name is required.
</div>
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" id="email" formControlName="email" class="form-control">
<div *ngIf="feedbackForm.get('email')?.invalid && (feedbackForm.get('email')?.dirty || feedbackForm.get('email')?.touched)"
class="alert alert-danger">
<span *ngIf="feedbackForm.get('email')?.errors?.['required']">Email is required.</span>
<span *ngIf="feedbackForm.get('email')?.errors?.['email']">Please enter a valid email.</span>
</div>
</div>
<div class="form-group">
<label>Overall Rating:</label>
<!-- Our custom star rating component, bound with formControlName! -->
<app-star-rating formControlName="rating" [maxStars]="5"></app-star-rating>
<div *ngIf="feedbackForm.get('rating')?.invalid && (feedbackForm.get('rating')?.dirty || feedbackForm.get('rating')?.touched)"
class="alert alert-danger">
Please provide a rating (at least 1 star).
</div>
</div>
<div class="form-group">
<label for="comments">Comments:</label>
<textarea id="comments" formControlName="comments" class="form-control"></textarea>
</div>
<button type="submit" [disabled]="feedbackForm.invalid" class="btn btn-primary">Submit Feedback</button>
<hr>
<h3>Form Actions:</h3>
<button type="button" (click)="setRatingTo(3)" class="btn btn-secondary mr-2">Set Rating to 3</button>
<button type="button" (click)="toggleRatingDisabled()" class="btn btn-secondary">Toggle Rating Disabled</button>
<pre>Form Status: {{ feedbackForm.status }}</pre>
<pre>Form Value: {{ feedbackForm.value | json }}</pre>
</form>
</div>
Let’s add some minimal global styling to src/styles.css for better readability of the form.
/* src/styles.css */
body {
font-family: Arial, sans-serif;
padding: 20px;
background-color: #f4f4f4;
}
.container {
max-width: 600px;
margin: 20px auto;
padding: 20px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-control {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box; /* Ensures padding doesn't expand width */
}
.alert {
padding: 8px;
margin-top: 5px;
border-radius: 4px;
color: #a94442;
background-color: #f2dede;
border: 1px solid #ebccd1;
}
.btn {
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
margin-right: 10px;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:disabled {
background-color: #a0c9ed;
cursor: not-allowed;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
pre {
background-color: #eee;
padding: 10px;
border-radius: 4px;
white-space: pre-wrap;
word-wrap: break-word;
}
Now, run ng serve and open your browser to http://localhost:4200.
Observe:
- You can now click on the stars to set a rating.
- The
Form Valuedisplay below the form updates immediately as you click stars. - If you try to submit without selecting a star, the
ratingfield will be invalid due toValidators.min(1), and the error message will show. - Clicking the “Set Rating to 3” button will programmatically update the star rating component to 3 stars.
- Clicking “Toggle Rating Disabled” will disable/enable the star rating, and you’ll see the visual change and inability to click stars when disabled.
Isn’t that neat? Your custom component now behaves exactly like a native input, thanks to ControlValueAccessor!
Switching from Template-Driven Forms (A Quick Note)
While this chapter focuses on Reactive Forms, it’s important to note that ControlValueAccessor is the underlying mechanism that allows both Reactive and Template-Driven forms to interact with custom components.
If you were migrating from a Template-Driven approach for a custom component, the ControlValueAccessor implementation within the custom component itself would remain largely the same. The difference would be in the parent component’s template:
Template-Driven (using ngModel):
<app-star-rating name="myRating" [(ngModel)]="myRatingValue" required></app-star-rating>
Here, ngModel would interact with the ControlValueAccessor to read/write values and manage validation. The required attribute would also be picked up by the forms system.
The core benefit of ControlValueAccessor is that it makes your custom component agnostic to the form type used by the parent, allowing for maximum reusability.
Mini-Challenge: Half-Star Ratings
You’ve built a solid star rating component. Now, let’s push it a bit further!
Challenge: Modify the StarRatingComponent to allow for half-star ratings.
This means:
- When a user clicks on the left half of a star, it should set a
.5rating (e.g., 2.5 stars). - When a user clicks on the right half of a star, it should set a whole number rating (e.g., 3.0 stars).
- The visual representation should also reflect half stars (e.g., a half-filled star).
Hint:
- You’ll need to modify the
rate(star: number)method to also consider the event object (MouseEvent) to determine where the click occurred relative to the star’s width. - You might need to adjust
isStarActiveand possibly thestarsarray or how you render the stars (e.g., using a different character or background-image for half stars, or two<span>s per star). - Consider how to represent half stars in your
ratingproperty (e.g.,2.5).
What to observe/learn: This challenge will deepen your understanding of how writeValue and onChange interact with a component’s internal state, and how to handle more granular user input within a custom form control. It also pushes you to think about visual representation for complex data.
Take your time, experiment, and don’t be afraid to search for ideas on how others have implemented half-star ratings visually!
Common Pitfalls & Troubleshooting
Even with clear steps, working with ControlValueAccessor can have its quirks. Here are a few common issues and how to resolve them:
Forgetting
NG_VALUE_ACCESSORProvider:- Symptom: Your custom component renders, but
formControlNameor[formControl]doesn’t seem to bind to it. TheFormControl’s value never changes when you interact with your component, andsetValue()calls from the parent don’t update your component. - Reason: Angular doesn’t know your component is designed to be a form control.
- Solution: Ensure you have the
providersarray correctly set up in your@Componentdecorator, includingNG_VALUE_ACCESSOR,useExisting,forwardRef, andmulti: true.
- Symptom: Your custom component renders, but
Not Calling
onChangeoronTouched:- Symptom: Your component’s UI updates, but the
FormControl’s value in the parent form (feedbackForm.value) doesn’t change. Validation messages might not appear correctly (ng-dirtyorng-touchedclasses are missing). - Reason: You’ve updated your component’s internal state, but haven’t told Angular’s forms system about it.
- Solution: Make sure you call
this.onChange(newValue)whenever your component’s value changes due to user interaction, andthis.onTouched()when the user has interacted with the component (e.g., onclickorblurevents).
- Symptom: Your component’s UI updates, but the
Circular Dependency with
forwardRef:- Symptom: You might get an error like “Cannot access ‘StarRatingComponent’ before initialization” or a similar circular dependency error during compilation.
- Reason: When
providersare defined, Angular tries to resolveStarRatingComponentbeforeStarRatingComponentitself is fully defined, leading to a loop. - Solution: Always wrap the reference to your component in
forwardRef(() => YourComponentClass)within theuseExistingproperty of theNG_VALUE_ACCESSORprovider. We already did this, but it’s a common mistake to forget.
Improper Handling of
setDisabledState:- Symptom: Calling
formControl.disable()orformControl.enable()works on other inputs, but your custom component doesn’t visually change or prevent user interaction. - Reason: You’ve implemented
setDisabledStatebut haven’t hooked it up to your component’s internal logic or template. - Solution:
- Ensure your
setDisabledStatemethod updates an internalisDisabledproperty. - Use this
isDisabledproperty in your template (e.g.,[class.disabled]="isDisabled") to apply visual styles. - Add conditional logic in your event handlers (like
rate()orhover()) to prevent actions whenthis.isDisabledis true.
- Ensure your
- Symptom: Calling
By carefully checking these points, you can debug most ControlValueAccessor related issues. The console is your best friend here!
Summary
You’ve just mastered a powerful technique in Angular forms! Let’s recap what we’ve covered:
ControlValueAccessoras a Bridge: It allows your custom components to seamlessly integrate with Angular’s Reactive (and Template-Driven) Forms API, behaving just like native HTML inputs.- Key Methods: You now understand the roles of
writeValue,registerOnChange,registerOnTouched, and the optionalsetDisabledState. These methods are the contract between your component and the forms system. NG_VALUE_ACCESSORToken: This special injection token, along withuseExisting,forwardRef, andmulti: true, is how you tell Angular that your component is aControlValueAccessor.- Practical Application: You successfully built a reusable
StarRatingComponentthat can be bound to aFormControl, accepts values programmatically, reports changes, handles validation, and responds to disabled states.
Building custom form controls with ControlValueAccessor is a fundamental skill for creating robust, maintainable, and highly reusable Angular applications. It empowers you to encapsulate complex UI logic while keeping your forms clean and declarative.
What’s Next?
In the next chapters, we’ll continue our deep dive into advanced Angular forms scenarios, including:
- Dynamic Forms: Building forms where fields can be added, removed, or changed based on user input or external data.
- Custom Async Validators: Creating validators that perform asynchronous operations (like checking if a username is available on a server).
- Form Arrays: Managing lists of form controls, perfect for scenarios like adding multiple items to an order.
Keep coding, keep exploring, and keep building amazing things with Angular!