Angular Components in Practice: Build Reusable Form Controls with ControlValueAccessor (Hands-On)

Angular Components in Practice: Build Reusable Form Controls with ControlValueAccessor (Hands-On)

Angular’s reactive forms are great—until you repeat the same markup, validation messages, and styling across five different screens. The fix is to encapsulate common UI patterns into reusable form components that behave like native inputs. The key is implementing ControlValueAccessor, so your component plugs into FormControl seamlessly (value, touched, disabled state, validators, etc.).

In this tutorial you’ll build a reusable <app-text-input> component that:

  • Works with formControlName (reactive forms)
  • Supports disabled, touched, and value updates
  • Displays validation errors in a consistent way
  • Is type-friendly and easy to reuse

1) Create the reusable text input component

Generate a component (CLI): ng generate component shared/text-input. Then implement ControlValueAccessor so Angular forms can read/write values through it.

text-input.component.ts

import { Component, Input, forwardRef } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-text-input',
templateUrl: './text-input.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TextInputComponent),
multi: true,
},
],
})
export class TextInputComponent implements ControlValueAccessor {
@Input() label = '';
@Input() placeholder = '';
@Input() type: 'text' | 'email' | 'password' | 'search' = 'text';
@Input() hint = '';
// Optional: pass custom error messages from the parent
@Input() errorMessages: Partial> = {};
value = '';
disabled = false;
touched = false;
// These are assigned by Angular Forms
private onChange: (value: string) => void = () => {};
private onTouched: () => void = () => {};
writeValue(value: string | null): void {
this.value = value ?? '';
}
registerOnChange(fn: (value: string) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
// Called on input event
handleInput(next: string) {
this.value = next;
this.onChange(next);
}
// Called on blur
handleBlur() {
this.touched = true;
this.onTouched();
}
}

2) Add the template with consistent UX

The template should look like a normal input but keep form wiring inside the component. We’ll also add an error area that the parent can toggle based on its form state.

text-input.component.html

<label class="field"> <span class="label">{{ label }}</span>

{{ hint }}



Notice the <ng-content> slot. This is a simple, flexible trick: the parent can provide error markup (using its access to the FormControl state), while the component controls layout/styling.

3) Use it in a reactive form

Now let’s build a small profile form and wire your new component with formControlName.

profile.component.ts

import { Component } from '@angular/core'; import { FormBuilder, Validators } from '@angular/forms';
@Component({
selector: 'app-profile',
templateUrl: './profile.component.html',
})
export class ProfileComponent {
constructor(private fb: FormBuilder) {}
form = this.fb.group({
fullName: ['', [Validators.required, Validators.minLength(3)]],
email: ['', [Validators.required, Validators.email]],
});
get fullName() {
return this.form.controls.fullName;
}
get email() {
return this.form.controls.email;
}
submit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
console.log('Form value:', this.form.value);
}
}

profile.component.html

<form [formGroup]="form" (ngSubmit)="submit()">

<div errors *ngIf="fullName.touched && fullName.invalid">
  <small *ngIf="fullName.errors?.['required']">Full name is required.</small>
  <small *ngIf="fullName.errors?.['minlength']">Full name is too short.</small>
</div>


<div errors *ngIf="email.touched && email.invalid">
  <small *ngIf="email.errors?.['required']">Email is required.</small>
  <small *ngIf="email.errors?.['email']">Please enter a valid email.</small>
</div>



At this point, your custom component behaves like a native input: the form can set values, read values, disable it, and track touched state.

4) Make it feel “native”: focus, disabled, and touched

Junior devs often get stuck on “Why doesn’t my component mark as touched?” The answer is that Angular can’t guess when your UI considers the field “touched.” You must call onTouched() (we did that on blur).

Also important:

  • writeValue() is called when the form sets a value (e.g., patchValue, reset, initial data).
  • registerOnChange() gives you a callback to push changes back to the form.
  • setDisabledState() lets the form control your component’s disabled state.

If any one of these is missing, your component will feel “almost right” but break in real apps (like edit forms, server-loaded data, or disabled controls).

5) Add a reusable error message helper (optional but practical)

Repeated error markup is still a thing. A clean pattern is: build a tiny helper function to map errors to messages, then render a single block in your templates.

error-messages.ts

import { AbstractControl } from '@angular/forms';
export function firstErrorMessage(
control: AbstractControl,
messages: Partial> = {}
): string | null {
if (!control.touched || !control.errors) return null;
const defaults: Record = {
required: 'This field is required.',
email: 'Please enter a valid email address.',
minlength: 'Value is too short.',
maxlength: 'Value is too long.',
};
const merged = { ...defaults, ...messages };
const firstKey = Object.keys(control.errors)[0];
return merged[firstKey] ?? 'Invalid value.';
}

Use it in the parent to keep templates lean:

<!-- profile.component.html (shorter error block) --> <app-text-input label="Email" type="email" formControlName="email"> <div errors *ngIf="email.touched && email.invalid"> <small>{{ firstErrorMessage(email) }}</small> </div> </app-text-input> 

profile.component.ts

import { firstErrorMessage } from './error-messages'; // ... firstErrorMessage = firstErrorMessage; 

6) Common pitfalls (and quick fixes)

  • Your component doesn’t update when the form value changes
    You forgot or mis-implemented writeValue(). Make sure it assigns to your internal value.

  • Touched/blur doesn’t work
    You didn’t call onTouched(). Trigger it on (blur) (or your equivalent interaction).

  • Disabled state doesn’t apply
    Implement setDisabledState() and bind [disabled]="disabled" to your actual input.

  • It works with reactive forms but not template-driven
    You can support both, but keep your focus on reactive forms for most modern Angular codebases. ControlValueAccessor is still the right building block.

7) Where to go next

Once you’ve built one solid CVA component, you can replicate the pattern for:

  • <app-select> with async options
  • <app-date-picker> that stores ISO strings
  • <app-tags-input> that stores string[]
  • <app-money-input> that formats display but stores a number

The win is consistency: every form control in your app can share the same layout, hints, and validation UX—without duplicating logic across pages.

If you want, I can write a follow-up that extends this into a MoneyInputComponent (format on display, store cents as an integer, validate min/max) or show how to integrate CVA components with Angular Material.


Leave a Reply

Your email address will not be published. Required fields are marked *