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 betweenpasswordandtext. -
Support
textarea: add an@Input() multilineand render<textarea>conditionally, still callinghandleInputandhandleBlur. -
Custom error messages: add
@Input() errorMessages: Record<string, string>and prefer those over defaults. -
Accessibility: add
idgeneration and bind<label [attr.for]="id">. Also consideraria-invalidandaria-describedby.
Troubleshooting: why errors don’t show
-
No
ReactiveFormsModule: if Angular can’t find the directives,formControlNamewon’t bind. -
Forgot
ngControl.valueAccessor = this: without that line, the component won’t connect to the form control when injectingNgControl. -
Only using
invalidwithout touched/submit logic: most UIs should avoid showing errors on first render. Usetouchedorsubmitted.
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