Build a Reusable Angular “Smart Input” Component with Validation (ControlValueAccessor)

Build a Reusable Angular “Smart Input” Component with Validation (ControlValueAccessor)

Angular forms are powerful—but many teams still copy-paste the same markup for inputs, labels, errors, and validation logic across dozens of pages. In this hands-on guide, you’ll build a reusable Angular form component that plugs into ReactiveFormsModule like a native control, supports validation, and keeps your templates clean.

By the end, you’ll have a <app-smart-input> component you can drop into any form and use with formControlName (or [formControl]), with consistent error messages and styling.

What we’re building

  • A custom input component that works with Angular Reactive Forms
  • Supports required, minLength, pattern, etc.
  • Shows validation errors only when the control is touched or the form is submitted
  • Supports disabling, placeholders, and input types (text/email/password)

Prerequisites

This article assumes you’re using Angular with Reactive Forms. The examples work in modern Angular versions and use a classic (non-signal) approach for widest compatibility.

Step 1: Create the component

Generate a component named smart-input:

ng generate component smart-input

Make sure your module (or standalone feature) imports ReactiveFormsModule where you use forms. If you’re using NgModules, you’ll typically have:

import { ReactiveFormsModule } from '@angular/forms'; @NgModule({ imports: [ReactiveFormsModule] }) export class AppModule {}

Step 2: Implement ControlValueAccessor

ControlValueAccessor is the interface Angular uses to connect custom components to the forms API. Implement it and register as a NG_VALUE_ACCESSOR.

// smart-input.component.ts import { Component, Input, Optional, Self } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl, ValidationErrors } from '@angular/forms'; @Component({ selector: 'app-smart-input', templateUrl: './smart-input.component.html', styleUrls: ['./smart-input.component.css'], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: SmartInputComponent, multi: true } ] }) export class SmartInputComponent implements ControlValueAccessor { @Input() label = ''; @Input() type: 'text' | 'email' | 'password' | 'number' = 'text'; @Input() placeholder = ''; @Input() hint = ''; // Optional: let parent pass a submitted flag to show errors on submit @Input() submitted = false; value = ''; disabled = false; // Callbacks registered by Angular forms private onChange: (value: string) => void = () => {}; private onTouched: () => void = () => {}; // Grab the underlying FormControl via NgControl constructor(@Optional() @Self() public ngControl: NgControl) { if (this.ngControl) { this.ngControl.valueAccessor = this; } } // ControlValueAccessor 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; } // UI handlers handleInput(event: Event): void { const target = event.target as HTMLInputElement; this.value = target.value; this.onChange(this.value); } handleBlur(): void { this.onTouched(); } // Helpers for validation UI get control() { return this.ngControl?.control; } get showErrors(): boolean { const c = this.control; if (!c) return false; return (c.touched || this.submitted) && !!c.errors; } get errors(): ValidationErrors | null { return this.control?.errors ?? null; } get firstErrorMessage(): string | null { const e = this.errors; if (!e) return null; if (e['required']) return 'This field is required.'; if (e['email']) return 'Please enter a valid email address.'; if (e['minlength']) return `Minimum length is ${e['minlength'].requiredLength}.`; if (e['maxlength']) return `Maximum length is ${e['maxlength'].requiredLength}.`; if (e['pattern']) return 'The value does not match the expected format.'; // fallback const firstKey = Object.keys(e)[0]; return firstKey ? `Invalid value (${firstKey}).` : 'Invalid value.'; } }

Why inject NgControl? This is the trick that lets your component read validation state (touched, errors, etc.) from the parent form control automatically—without forcing parents to pass the control down manually.

Step 3: Add the template and error UI

Now create a clean template: label, input, hint, and error message. Notice we bind to value and call handleInput/handleBlur.

<!-- smart-input.component.html --> <div class="field"> <label class="label" *ngIf="label">{{ label }}</label> <input class="input" [attr.type]="type" [attr.placeholder]="placeholder" [value]="value" [disabled]="disabled" (input)="handleInput($event)" (blur)="handleBlur()" [class.input--invalid]="showErrors" /> <small class="hint" *ngIf="hint && !showErrors">{{ hint }}</small> <small class="error" *ngIf="showErrors"> {{ firstErrorMessage }} </small> </div>

And a little CSS (keep it simple and consistent):

/* smart-input.component.css */ .field { display: grid; gap: 6px; margin-bottom: 14px; } .label { font-weight: 600; } .input { padding: 10px 12px; border: 1px solid #d0d7de; border-radius: 8px; outline: none; } .input:focus { border-color: #6ea8fe; } .input--invalid { border-color: #d1242f; } .hint { color: #57606a; } .error { color: #d1242f; font-weight: 600; }

Step 4: Use it in a real form

Create a login form and replace your repeated input markup with <app-smart-input>. You can pass a submitted flag to show errors after the user clicks submit.

// login.component.ts import { Component } from '@angular/core'; import { FormBuilder, Validators } from '@angular/forms'; @Component({ selector: 'app-login', templateUrl: './login.component.html' }) export class LoginComponent { submitted = false; form = this.fb.group({ email: ['', [Validators.required, Validators.email]], password: ['', [Validators.required, Validators.minLength(8)]] }); constructor(private fb: FormBuilder) {} submit(): void { this.submitted = true; if (this.form.invalid) { // Mark all as touched so errors show consistently this.form.markAllAsTouched(); return; } const value = this.form.value; console.log('Login payload:', value); } }
<!-- login.component.html --> <form [formGroup]="form" (ngSubmit)="submit()"> <app-smart-input label="Email" type="email" placeholder="[email protected]" hint="We’ll never share your email." formControlName="email" [submitted]="submitted" ></app-smart-input> <app-smart-input label="Password" type="password" placeholder="At least 8 characters" formControlName="password" [submitted]="submitted" ></app-smart-input> <button type="submit">Sign in</button> <p *ngIf="submitted && form.valid">Form is valid ✅</p> </form>

That’s it—your custom component now behaves like a built-in form control.

Step 5: Common upgrades (copy-paste friendly)

  • Add “show password” toggle (for type="password"): add a button next to the input that switches the effective type between password and text.

  • Support textarea: add an @Input() multiline and render <textarea> conditionally, still calling handleInput and handleBlur.

  • Custom error messages: add @Input() errorMessages: Record<string, string> and prefer those over defaults.

  • Accessibility: add id generation and bind <label [attr.for]="id">. Also consider aria-invalid and aria-describedby.

Troubleshooting: why errors don’t show

  • No ReactiveFormsModule: if Angular can’t find the directives, formControlName won’t bind.

  • Forgot ngControl.valueAccessor = this: without that line, the component won’t connect to the form control when injecting NgControl.

  • Only using invalid without touched/submit logic: most UIs should avoid showing errors on first render. Use touched or submitted.

Where this pattern shines

Once you have a base component like this, you can standardize input styling and validation across your app. Teams often evolve this into a small internal “form kit”:

  • Smart select dropdowns and date pickers (also via ControlValueAccessor)
  • Consistent form layouts with labels/hints/errors
  • One place to enforce accessibility patterns
  • Fewer template bugs and less repeated code during feature work

If you want a next step, build a matching <app-smart-select> and <app-smart-textarea> using the same approach—your forms will get cleaner with every component you add.


Leave a Reply

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