Learn how to enhance Angular form validations using Reactive Forms and a few gimmicks.
Input + Time based validation trigger
Form submit triggers errors to display
| <app-todo-form></app-todo-form> |
| import { NgModule } from '@angular/core'; | |
| import { BrowserModule } from '@angular/platform-browser'; | |
| import { AppComponent } from './app.component'; | |
| import { TodoFormModule } from './todos/todo-form/todo-form.module'; | |
| @NgModule({ | |
| declarations: [ | |
| AppComponent, | |
| ], | |
| imports: [ | |
| BrowserModule, | |
| TodoFormModule, | |
| ], | |
| providers: [], | |
| bootstrap: [AppComponent] | |
| }) | |
| export class AppModule { } |
| <form [formGroup]="todoForm" (ngSubmit)="onSubmit()"> | |
| <h2>Create new todo</h2> | |
| <!-- Title --> | |
| <div class="title-wrapper"> | |
| <label>Title</label> | |
| <input formControlName="title" type="text" /> | |
| <div class="error-message" *ngIf="formFieldCanBeValidated['title'] && titleControl.invalid && (titleControl.dirty || titleControl.touched)"> | |
| <div *ngIf="titleControl.hasError('required')">Title is required</div> | |
| <div *ngIf="titleControl.hasError('maxlength')">Title is too long</div> | |
| <div *ngIf="titleControl.hasError('minlength')">Title is too short</div> | |
| <div *ngIf="titleControl.hasError('pattern')">Title contains numbers or symbols</div> | |
| </div> | |
| </div> | |
| <!-- Description --> | |
| <div class="description-wrapper"> | |
| <label>Description</label> | |
| <textarea formControlName="description"></textarea> | |
| <div class="error-message" *ngIf="formFieldCanBeValidated['description'] && descriptionControl.invalid && (descriptionControl.dirty || descriptionControl.touched)"> | |
| Maximum number of characters is 300! | |
| </div> | |
| </div> | |
| <button class="submit-btn" type="submit">Submit</button> | |
| </form> |
| form { | |
| display: flex; | |
| flex-direction: column; | |
| width: 500px; | |
| box-shadow: rgba(0, 0, 0, 0.35) 0px 5px 15px; | |
| padding: 5%; | |
| margin: 5%; | |
| } | |
| .title-wrapper { | |
| width: 100%; | |
| margin-bottom: 2rem; | |
| & > input { | |
| width: 100%; | |
| border-radius: 5px; | |
| border: 1px solid #007BFF; | |
| outline: none; | |
| height: 20px; | |
| padding: 0 5px; | |
| box-sizing: border-box; | |
| height: 30px; | |
| } | |
| } | |
| .description-wrapper { | |
| width: 100%; | |
| & > textarea { | |
| width: 100%; | |
| border-radius: 5px; | |
| border: 1px solid #007BFF; | |
| outline: none; | |
| resize: none; | |
| height: 80px; | |
| box-sizing: border-box; | |
| } | |
| } | |
| .submit-btn { | |
| cursor: pointer; | |
| background: #007BFF; | |
| color: #FFF; | |
| margin-top: 20px; | |
| height: 30px; | |
| border: none; | |
| &:hover { | |
| background: #2890fe; | |
| } | |
| } | |
| .error-message { | |
| color: red; | |
| } |
| import { Component, OnDestroy, OnInit } from '@angular/core'; | |
| import { | |
| AbstractControl, | |
| FormBuilder, | |
| FormGroup, | |
| Validators, | |
| } from '@angular/forms'; | |
| import { debounceTime, Subject, takeUntil, tap } from 'rxjs'; | |
| enum FormFields { | |
| Title = 'title', | |
| Description = 'description', | |
| } | |
| @Component({ | |
| selector: 'app-todo-form', | |
| templateUrl: './todo-form.component.html', | |
| styleUrls: ['./todo-form.component.scss'], | |
| }) | |
| export class TodoFormComponent implements OnInit, OnDestroy { | |
| todoForm!: FormGroup; | |
| private readonly unsubscribed$ = new Subject<void>(); | |
| formFieldCanBeValidated = { | |
| [FormFields.Title]: true, | |
| [FormFields.Description]: true, | |
| }; | |
| constructor(private fb: FormBuilder) {} | |
| ngOnInit(): void { | |
| this.setupForm(); | |
| this.toggleValidationRules(FormFields.Title); | |
| this.toggleValidationRules(FormFields.Description); | |
| } | |
| ngOnDestroy(): void { | |
| this.unsubscribed$.next(); | |
| this.unsubscribed$.complete(); | |
| } | |
| private toggleValidationRules(field: FormFields) { | |
| this.todoForm | |
| .get(field) | |
| ?.valueChanges.pipe( | |
| // clear validation as soon you start typing | |
| tap(() => (this.formFieldCanBeValidated[field] = false)), | |
| // hold for 500ms after user stopped typing | |
| debounceTime(500), | |
| takeUntil(this.unsubscribed$) | |
| ) | |
| .subscribe(() => (this.formFieldCanBeValidated[field] = true)); | |
| } | |
| get titleControl(): AbstractControl { | |
| return this.todoForm.get(FormFields.Title) as AbstractControl; | |
| } | |
| get descriptionControl(): AbstractControl { | |
| return this.todoForm.get(FormFields.Description) as AbstractControl; | |
| } | |
| private setupForm(): void { | |
| this.todoForm = this.fb.group({ | |
| [FormFields.Title]: [ | |
| '', | |
| [ | |
| Validators.required, | |
| Validators.minLength(3), | |
| Validators.maxLength(100), | |
| Validators.pattern(new RegExp(/^[a-zA-Z\s]+$/)), | |
| ], | |
| ], | |
| [FormFields.Description]: ['', [Validators.maxLength(300)]], | |
| }); | |
| } | |
| private triggerValidationOnSubmit(): void { | |
| Object.keys(this.todoForm.controls).forEach((field: string) => { | |
| const control = this.todoForm.get(field); | |
| control?.markAsTouched({ onlySelf: true }); | |
| }); | |
| } | |
| onSubmit(): void { | |
| console.log(this.todoForm.value); | |
| if (!this.todoForm.valid) { | |
| this.triggerValidationOnSubmit(); | |
| return; | |
| } | |
| console.log('done'); | |
| } | |
| } |
| import { NgModule } from '@angular/core'; | |
| import { TodoFormComponent } from './todo-form.component'; | |
| import { ReactiveFormsModule } from '@angular/forms'; | |
| import { CommonModule } from '@angular/common'; | |
| @NgModule({ | |
| declarations: [ | |
| TodoFormComponent | |
| ], | |
| imports: [ | |
| CommonModule, | |
| ReactiveFormsModule | |
| ], | |
| exports: [ | |
| TodoFormComponent | |
| ] | |
| }) | |
| export class TodoFormModule { } |