Have you ever wanted to create a form that tracks user behavior and automatically manages validations? With the use of Angular Reactive forms, you can do just that. This blog will teach you how to:
- Create a form from scratch
- keep a field clean before it is interacted with
- display validations on touch
- clear validations while the user is typing
- if invalid, trigger validation as soon as the user stops typing
- trigger validations in case the user clicks on the submit button before interacting with mandatory fields
Setup
Creating a new Angular project
ng new my-app --standalone=false
Create a new module and a new component.
ng g m todos/todo-form
ng g c todos/todo-form
Creating Modules
Todo Form Module
Add the Reactive Forms Module to your module file:
@NgModule({
declarations: [
TodoFormComponent
],
imports: [
CommonModule,
ReactiveFormsModule
],
exports: [
TodoFormComponent
]
})
export class TodoFormModule { }
Add Todo Form Module to the App Module
app.module.ts
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 { }
Now you should be able to use a todo form in App Component.
app.component.ts
<app-todo-form></app-todo-form>
Creating form skeleton
The form contains two fields title and description.
todo-form.component.ts
import { AbstractControl, FormBuilder, FormGroup, Validators } from '@angular/forms';
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 {
todoForm!: FormGroup;
constructor(
private fb: FormBuilder
) {}
ngOnInit(): void {
this.setupForm();
}
// form structure
private setupForm(): void {
this.todoForm = this.fb.group({
[FormFields.Title]: [''], // default values
[FormFields.Description]: ['']
});
}
onSubmit(): void {
}
}
todo-form.component.html
<form [formGroup]="todoForm" (ngSubmit)="onSubmit()">
<div class="title-wrapper">
<input formControlName="title" type="text" />
</div>
<div class="description-wrapper">
<textarea formControlName="description"></textarea>
</div>
<!-- This button invokes onSubmit() method upon clicking -->
<button class="submit-btn" type="submit">Submit</button>
</form>
Validations
Adding Form Validations
The todo title has several validations:
- it's required
- has a minimum length of characters
- has a maximum length of 100 characters
- must use only letters (Regex)
The description field has only length validation - up to 300 characters allowed.
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)]],
});
}
Create Getters
Create a getter for each form control to access it in the template easily:
get titleControl(): AbstractControl {
return this.todoForm.get(FormFields.Title) as AbstractControl;
}
get descriptionControl(): AbstractControl {
return this.todoForm.get(FormFields.Description) as AbstractControl;
}
Applying Validations in the template
todo-form.component.html
<!-- Title -->
<div class="title-wrapper">
<label>Title</label>
<input formControlName="title" type="text" />
<div class="error-message" *ngIf="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="descriptionControl.invalid && (descriptionControl.dirty || descriptionControl.touched)">
Maximum number of characters is 300!
</div>
</div>
Clarifications
To prevent errors from popping up as soon as the page loads, you want to add the following line as a prerequisite:
titleControl.invalid && (titleControl.dirty || titleControl.touched)
What it does is validate field behavior.
- If control validation failed,
control.invalid
will betrue
- If the control is
dirty
ortouched
means the user either clicked or started typing on input
Once the user interacts with the form and the form is in an invalid state, then you proceed with validating each validation rule set in the TypeScript component file:
Component | Template |
---|---|
Validators.required | titleControl.hasError('required') |
Validators.minLength(3) | titleControl.hasError('minlength') |
Validators.maxLength(100) | titleControl.hasError('maxlength') |
Validators.pattern(new RegExp(...)) | titleControl.hasError('pattern') |
And display an appropriate error for each.
Trigger validations after the user stops typing
Create a dictionary-like structure that holds form fields.
todo-form.component.ts
formFieldCanBeValidated = {
[FormFields.Title]: true,
[FormFields.Description]: true
}
The FormGroup (TodoForm) exposes a valueChanges
property that is available on each form control. The valueChanges
returns an Observable, that you can pipe operators to and then subscribe.
- In this case, use the
debounceTime(n)
operator that emits the Observable only after then
number of milliseconds passed. - In between, toggle field state via
this.formFieldCanBeValidated[field]
using thetap()
operator and thesubscribe()
function.
Now create a function that toggles validation rules when a user starts and stops typing.
todo-form.component.ts
// this will enable/disable validation for each field (title or description)
private toggleValidationRules(field: FormFields) {
this.todoForm.get(field)?.valueChanges
.pipe(
// clear validation as soon the user starts typing
tap(() => this.formFieldCanBeValidated[field] = false),
// hold for 500ms after user stopped typing
debounceTime(500),
)
// set validation when user stops
.subscribe(() => this.formFieldCanBeValidated[field] = true)
}
Call the function above in ngOnInit for each form field
ngOnInit(): void {
this.setupForm();
this.toggleValidationRules(FormFields.Title)
this.toggleValidationRules(FormFields.Description)
}
Finally, use formFieldCanBeValidated
in the template.
todo-form.component.html
<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>
This ensures that validation and error messages in the template are displayed only when a user stops using the form field for 500ms or in other words, when formFieldCanBeValidated['title'] = true
.
Prevent form submit
Using Reactive forms you can prevent form submission if the form is in an invalid state. You can verify the form validity using the valid
property on the FormGroup (TodoForm).
todo-form.component.ts
onSubmit(): void {
// will not pass this line if there is any error on the form
if (!this.todoForm.valid) {
return
}
// read form values
console.log(this.todoForm.values);
// {title: 'Hello', description: 'World'}
}
A common practice is to disable the submit button until the form is in a valid state.
<!-- This button invokes onSubmit() method upon clicking -->
<button class="submit-btn" type="submit" [disabled]="!todoForm.valid">Submit</button>
However, you can trigger validation rules to display on UI on submit if the user hasn't interacted with the form.
Create a function that validates all form fields and marks them as touched:
todo-form.component.ts
private triggerValidationOnSubmit() {
Object.keys(this.todoForm.controls).forEach(field => {
const control = this.todoForm.get(field);
control.markAsTouched({ onlySelf: true });
});
}
Apply the previous function in the onSubmit()
call.
onSubmit(): void {
// will not pass this line if there is any error on the form
if (!this.todoForm.valid) {
this.triggerValidationOnSubmit();
return;
}
// ... do other stuff
Now, when a user hits the submit button ahead of time, it will display errors for all invalid fields.
Clean Observables
Finally, unsubscribe from all active Observables to prevent memory leaks.
1) Create a subject to track Observables
todo-form.component.ts
private readonly unsubscribed$ = new Subject<void>();
2) Put the subject inside the pipe
private toggleValidationRules(field: FormFields) {
this.todoForm.get(field)?.valueChanges
.pipe(
tap(() => this.formFieldCanBeValidated[field] = false),
debounceTime(500),
takeUntil(this.unsubscribed$) // <-- subject to unsubscribe
)
.subscribe(() => this.formFieldCanBeValidated[field] = true)
}
3) Unsubscribe once the component is destroyed
export class TodoFormComponent implements OnInit, OnDestroy
...
ngOnDestroy(): void {
this.unsubscribed$.next();
this.unsubscribed$.complete();
}
That's all from me today.
If you'd like to learn more be sure to check out my other blog on Medium and follow me on Twitter to stay up to date with my content updates.
Previous Chapter ⬅️
Get Full Code
Bye for now 👋
Top comments (0)