DEV Community

Cover image for Reactive Validation Triggers in Angular Forms
Mirza Leka
Mirza Leka

Posted on • Updated on

Reactive Validation Triggers in Angular Forms

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
Enter fullscreen mode Exit fullscreen mode

Create a new module and a new component.

ng g m todos/todo-form
ng g c todos/todo-form
Enter fullscreen mode Exit fullscreen mode

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 { }
Enter fullscreen mode Exit fullscreen mode

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 { }
Enter fullscreen mode Exit fullscreen mode

Now you should be able to use a todo form in App Component.

app.component.ts
  <app-todo-form></app-todo-form>
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode

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)]],
    });
  }
Enter fullscreen mode Exit fullscreen mode

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;
  }
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

What it does is validate field behavior.

  • If control validation failed, control.invalid will be true
  • If the control is dirty or touched 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
  }
Enter fullscreen mode Exit fullscreen mode

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 the n number of milliseconds passed.
  • In between, toggle field state via this.formFieldCanBeValidated[field] using the tap() operator and the subscribe() 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)
  }
Enter fullscreen mode Exit fullscreen mode

Call the function above in ngOnInit for each form field

  ngOnInit(): void {
    this.setupForm();

    this.toggleValidationRules(FormFields.Title)
    this.toggleValidationRules(FormFields.Description)
  }
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

  }
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 });
  });
}

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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)
  }
Enter fullscreen mode Exit fullscreen mode

3) Unsubscribe once the component is destroyed

export class TodoFormComponent implements OnInit, OnDestroy 

...
  ngOnDestroy(): void {
    this.unsubscribed$.next();
    this.unsubscribed$.complete();
  }
Enter fullscreen mode Exit fullscreen mode

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)