Angular Reusable Form Components in Practice: ControlValueAccessor + Validators + Standalone Components

Angular Reusable Form Components in Practice: ControlValueAccessor + Validators + Standalone Components

Building Angular apps gets repetitive fast: every feature needs an input, a dropdown, a date picker, a “required” message, a loading state, and consistent styling. Copy-pasting template snippets works… until it doesn’t. The practical solution is to build reusable form components that plug into Angular Reactive Forms like native inputs.

In this hands-on guide, you’ll build a reusable <app-text-field> component that:

  • Works with formControlName and FormControl
  • Supports disabled state automatically
  • Shows validation errors only when appropriate (touched/dirty/submitted)
  • Optionally exposes “required” behavior and custom error messages

The key is implementing ControlValueAccessor, which is Angular’s contract for custom controls.

1) Create a Standalone Text Field Component

This example uses Angular standalone components (no NgModule required). Generate one:

ng generate component shared/text-field --standalone

Now implement ControlValueAccessor. This allows Angular forms to call your component with values, listen for changes, and toggle disabled state.

// shared/text-field/text-field.component.ts import { Component, Input, Optional, Self } from '@angular/core'; import { ControlValueAccessor, NgControl, ReactiveFormsModule, ValidationErrors } from '@angular/forms'; import { CommonModule } from '@angular/common'; @Component({ selector: 'app-text-field', standalone: true, imports: [CommonModule, ReactiveFormsModule], templateUrl: './text-field.component.html', }) export class TextFieldComponent implements ControlValueAccessor { @Input() label = ''; @Input() placeholder = ''; @Input() type: 'text' | 'email' | 'password' | 'number' = 'text'; @Input() hint = ''; @Input() autocomplete: string | null = null; // Optional: override default messages @Input() errorMessages: Record<string, string> = { required: 'This field is required.', minlength: 'Too short.', maxlength: 'Too long.', email: 'Please enter a valid email.', pattern: 'Invalid format.', }; value = ''; isDisabled = false; // ControlValueAccessor callbacks private onChange: (value: string) => void = () => {}; private onTouched: () => void = () => {}; constructor(@Optional() @Self() public ngControl: NgControl) { // Connect this component to Angular's forms API automatically. if (this.ngControl) { this.ngControl.valueAccessor = this; } } // Called by Angular when the form wants to set a value writeValue(value: unknown): void { this.value = (value ?? '') as string; } // Called by Angular to register change callback registerOnChange(fn: (value: string) => void): void { this.onChange = fn; } // Called by Angular to register touched callback registerOnTouched(fn: () => void): void { this.onTouched = fn; } // Called by Angular when control should be disabled/enabled setDisabledState(isDisabled: boolean): void { this.isDisabled = isDisabled; } // UI event handlers handleInput(value: string) { this.value = value; this.onChange(value); } handleBlur() { this.onTouched(); } get errors(): ValidationErrors | null { return this.ngControl?.control?.errors ?? null; } get showErrors(): boolean { const c = this.ngControl?.control; if (!c) return false; // Show after user interaction return !!(c.invalid && (c.touched || c.dirty)); } firstErrorKey(): string | null { const e = this.errors; if (!e) return null; return Object.keys(e)[0] ?? null; } errorText(): string | null { const key = this.firstErrorKey(); if (!key) return null; // Special-case some validators to show meaningful details const e = this.errors ?? {}; if (key === 'minlength') { const req = (e['minlength'] as any).requiredLength; return `Minimum length is ${req}.`; } if (key === 'maxlength') { const req = (e['maxlength'] as any).requiredLength; return `Maximum length is ${req}.`; } return this.errorMessages[key] ?? 'Invalid value.'; } }

2) Build the Template with Accessible Markup

Keep the component simple: a label, an input, optional hint, and error message. Also wire up ARIA attributes so screen readers get helpful info.

<!-- shared/text-field/text-field.component.html --> <div class="field"> <label class="label">{{ label }}</label> <input class="input" [attr.type]="type" [attr.placeholder]="placeholder" [attr.autocomplete]="autocomplete" [disabled]="isDisabled" [value]="value" (input)="handleInput(($any($event.target)).value)" (blur)="handleBlur()" [attr.aria-invalid]="showErrors" [attr.aria-describedby]="hint ? 'hint' : null" /> <small *ngIf="hint" id="hint" class="hint">{{ hint }}</small> <div *ngIf="showErrors" class="error" role="alert"> {{ errorText() }} </div> </div>

If you want, style it with your design system or a utility framework. The important part is that it behaves like a form control.

3) Use It in a Reactive Form

Now you can drop it into any form. Here’s a minimal login component using FormBuilder and validators:

// login.component.ts import { Component } from '@angular/core'; import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { CommonModule } from '@angular/common'; import { TextFieldComponent } from '../shared/text-field/text-field.component'; @Component({ selector: 'app-login', standalone: true, imports: [CommonModule, ReactiveFormsModule, TextFieldComponent], templateUrl: './login.component.html', }) export class LoginComponent { loading = false; form = this.fb.group({ email: ['', [Validators.required, Validators.email]], password: ['', [Validators.required, Validators.minLength(8)]], }); constructor(private fb: FormBuilder) {} async submit() { if (this.form.invalid) { // Mark all controls touched so errors appear this.form.markAllAsTouched(); return; } this.loading = true; try { // Simulate request await new Promise((r) => setTimeout(r, 800)); console.log('Login payload', this.form.value); } finally { this.loading = false; } } }
<!-- login.component.html --> <form [formGroup]="form" (ngSubmit)="submit()"> <app-text-field label="Email" placeholder="[email protected]" type="email" autocomplete="email" hint="Use your work email" formControlName="email" ></app-text-field> <app-text-field label="Password" placeholder="••••••••" type="password" autocomplete="current-password" formControlName="password" ></app-text-field> <button type="submit" [disabled]="loading"> {{ loading ? 'Signing in…' : 'Sign in' }} </button> </form>

Notice: you didn’t write any per-form validation rendering. The component centralizes that logic.

4) Handling “Submitted” Error Display (Common Real-World Need)

Many teams prefer errors to appear only after the user clicks “Submit” at least once. A practical pattern is to pass a submitted flag from the parent to the control and include it in the component’s show logic.

Update the component:

// text-field.component.ts (add input + adjust showErrors) @Input() submitted = false; get showErrors(): boolean { const c = this.ngControl?.control; if (!c) return false; // Show after interaction OR after submit attempt return !!(c.invalid && (c.touched || c.dirty || this.submitted)); }

Then in the parent:

// login.component.ts submitted = false; async submit() { this.submitted = true; if (this.form.invalid) return; // ... }
<!-- login.component.html --> <app-text-field label="Email" formControlName="email" [submitted]="submitted" ></app-text-field>

This keeps UX consistent across the app and avoids “error spam” while users type.

5) Bonus: Making the Component Support Non-String Values

Text inputs are strings, but sometimes you want numbers. You can keep the component as string-only (simpler), or support parsing:

  • If type="number", emit a number (or null)
  • Otherwise emit a string

A lightweight approach is to keep the internal state as a string but transform on emit:

// text-field.component.ts (replace handleInput) handleInput(raw: string) { this.value = raw; if (this.type === 'number') { const n = raw.trim() === '' ? null : Number(raw); this.onChange(Number.isNaN(n) ? null : (n as any)); return; } this.onChange(raw); }

If you do this, make sure your form control type allows null and number as needed.

6) Practical Checklist for Shipping Reusable Controls

  • ControlValueAccessor is required for formControlName support.
  • Always implement setDisabledState so your control respects form-level disabling.
  • Make validation display predictable: touched/dirty/submitted patterns.
  • Keep error messages centralized but overridable (teams will want custom copy).
  • Consider accessibility: label, aria-invalid, and role="alert" for errors.
  • Don’t over-generalize early. Build 1–2 controls first (text field + select), then extract shared helpers.

Wrap-Up

Reusable Angular form controls are one of the highest ROI upgrades you can make in a growing codebase. With a small amount of upfront work (ControlValueAccessor + a consistent validation policy), you get:

  • Less copy/paste across features
  • More consistent UX and error handling
  • Cleaner templates and faster development

Next step: clone this pattern to create <app-select> (dropdown) and <app-textarea> components, keeping the same validation behavior so the whole app feels coherent.


Leave a Reply

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