Build a Reusable Angular Form Component with ControlValueAccessor (That Works with Reactive Forms)

Build a Reusable Angular Form Component with ControlValueAccessor (That Works with Reactive Forms)

Angular’s reactive forms are fantastic—until you try to reuse form UI across screens. Copy-pasting inputs, validation hints, and error handling quickly becomes messy. The solution is to build real form components that behave like native controls (e.g., <input>) so they plug into Angular forms cleanly.

In this hands-on guide, you’ll build a reusable “Text Field” component using ControlValueAccessor (CVA). It will support:

  • formControlName / formControl (Reactive Forms)
  • Disabled state
  • Touched/dirty behavior
  • Validation messages
  • Optional “required” asterisk + helper text

You’ll end with a component that feels native in forms—junior/mid dev superpower.

1) Why ControlValueAccessor?

If you create a component like <app-text-field> and simply pass [value] and (input), Angular forms won’t automatically understand it. CVA is the “adapter” that teaches Angular:

  • How to write a value into your component
  • How your component reports changes back
  • How your component marks itself touched
  • How it reacts to disabled

Once implemented, you can use your custom component exactly like built-in controls.

2) Create a Standalone Text Field Component

This example uses a standalone component (no NgModule needed). Generate it:

ng generate component shared/text-field --standalone

Now implement the component as a CVA.

3) Implement the CVA (TypeScript)

import { Component, Input, forwardRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, NG_VALIDATORS, Validator, AbstractControl, ValidationErrors } from '@angular/forms'; @Component({ selector: 'app-text-field', standalone: true, imports: [CommonModule], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TextFieldComponent), multi: true }, { provide: NG_VALIDATORS, useExisting: forwardRef(() => TextFieldComponent), multi: true } ], templateUrl: './text-field.component.html' }) export class TextFieldComponent implements ControlValueAccessor, Validator { @Input() label = ''; @Input() placeholder = ''; @Input() type: 'text' | 'email' | 'password' | 'search' = 'text'; @Input() helperText = ''; @Input() required = false; // Internal value value = ''; disabled = false; // Callbacks provided by Angular forms private onChange: (value: string) => void = () => {}; private onTouched: () => void = () => {}; // --- CVA methods --- 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 from template on input event handleInput(value: string): void { this.value = value; this.onChange(value); } // Called from template on blur handleBlur(): void { this.onTouched(); } // --- Optional: component-level validation --- // This allows the component to participate in validation if you want built-in rules. validate(control: AbstractControl): ValidationErrors | null { if (this.required && !control.value) { return { required: true }; } // You can add more component-level rules here if needed. return null; } }

Key idea: writeValue sets the displayed value. onChange notifies Angular when the user types. onTouched notifies Angular when the field is blurred (important for showing errors only after interaction).

4) Build the Template (HTML)

Here’s a basic but practical UI with label, helper text, and error display. Notice: the error logic is typically driven by the parent form control, but we can still make this component ergonomic by accepting “required” and rendering helpful UI.

<div class="field"> <label class="label"> {{ label }} <span *ngIf="required" aria-hidden="true">*</span> </label> <input class="input" [attr.type]="type" [attr.placeholder]="placeholder" [value]="value" [disabled]="disabled" (input)="handleInput(($event.target as HTMLInputElement).value)" (blur)="handleBlur()" /> <small class="helper" *ngIf="helperText">{{ helperText }}</small> </div>

Styling is up to you (CSS/SCSS), but keeping consistent class names across shared components makes your design system easier later.

5) Use It in a Real Reactive Form

Now the payoff: use <app-text-field> with formControlName like it’s native.

5a) Create a Form Component

import { Component } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { CommonModule } from '@angular/common'; import { TextFieldComponent } from '../shared/text-field/text-field.component'; @Component({ selector: 'app-signup', standalone: true, imports: [CommonModule, ReactiveFormsModule, TextFieldComponent], templateUrl: './signup.component.html' }) export class SignupComponent { constructor(private fb: FormBuilder) {} form = this.fb.group({ email: ['', [Validators.required, Validators.email]], password: ['', [Validators.required, Validators.minLength(8)]] }); submit(): void { if (this.form.invalid) { this.form.markAllAsTouched(); return; } console.log('Submit', this.form.value); } get emailCtrl() { return this.form.get('email'); } get passwordCtrl() { return this.form.get('password'); } }

5b) Signup Template with Error Messages

In real projects, error messages are often best handled at the form level (because different screens may want different text). Here’s a clean, readable pattern.

<form [formGroup]="form" (ngSubmit)="submit()"> <app-text-field label="Email" placeholder="[email protected]" type="email" [required]="true" helperText="We’ll never share your email." formControlName="email" ></app-text-field> <p class="error" *ngIf="emailCtrl?.touched && emailCtrl?.errors"> <span *ngIf="emailCtrl?.errors?.['required']">Email is required.</span> <span *ngIf="emailCtrl?.errors?.['email']">Enter a valid email address.</span> </p> <app-text-field label="Password" placeholder="At least 8 characters" type="password" [required]="true" formControlName="password" ></app-text-field> <p class="error" *ngIf="passwordCtrl?.touched && passwordCtrl?.errors"> <span *ngIf="passwordCtrl?.errors?.['required']">Password is required.</span> <span *ngIf="passwordCtrl?.errors?.['minlength']"> Password must be at least 8 characters. </span> </p> <button type="submit" [disabled]="form.invalid">Create account</button> </form>

That’s it: the custom component participates in the form, receives values, updates the form state, and becomes disabled when the form control is disabled.

6) Common Pitfalls (and How to Avoid Them)

  • Forgetting onTouched: If you don’t call it on blur, the control may never be “touched,” and your UI won’t show errors when expected.

  • Not implementing setDisabledState: Disabling the form control won’t disable your input.

  • Two sources of truth: Keep a single internal value and always update it in writeValue and in user handlers.

  • Confusing validation responsibilities: Put business rules (like email format, min length) on the form control with Validators. Use component validation only for UI-specific rules you truly want baked into the component.

7) Upgrade It: Support Textarea and Prefix/Suffix

Once you’re comfortable, you can evolve the same pattern to:

  • Textarea component (<textarea>) for descriptions
  • Number input with min/max
  • Prefix/suffix icons (search, clear button, password visibility toggle)
  • Masked inputs (phone, currency)

The CVA core stays the same; you’re just changing the UI and event wiring.

8) Quick Checklist for Production-Ready Form Components

  • ControlValueAccessor implemented: writeValue, registerOnChange, registerOnTouched, setDisabledState
  • Calls onChange on user updates
  • Calls onTouched on blur
  • Respects disabled state
  • Has consistent label/helper/error layout
  • Has accessible labeling (e.g., label tied to input via for/id if you extend it)

Wrap-up

Reusable form components are one of the highest ROI improvements you can make in an Angular codebase. With a simple CVA implementation, your custom UI behaves like a native control—clean templates, consistent UX, and far less duplication.

If you want a next step, build a <app-select-field> that supports options, search, and async loading—using the exact same CVA approach.


Leave a Reply

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