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 { } |