Angular Reactive Forms Guide

Mirza Leka
14 min readSep 30, 2024

--

This article is a comprehensive guide to the vast possibilities of Reactive Forms in Angular. In this first part, we’ll learn why the benefits of Reactive Forms, how to create a first form, different validation strategies, and the bond between the Reactive forms and the Observables.

Why Reactive forms?

To get started, let’s try to understand why Reactive forms exist. Here is an example of a basic form:

  • HTML Template
<div class="form-wrapper">

<h1>Sign up form example</h1>

<form (submit)="handleSubmit($event)">

<input type="text" placeholder="username" id="username" class="form-field-input" />
<input type="password" placeholder="password" id="password" class="form-field-input"/>
<button type="submit" class="button-primary">Sign up</button>

</form>

</div>
  • TypeScript Component
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent implements OnInit {

usernameInput!: HTMLInputElement;
passwordInput!: HTMLInputElement;

ngOnInit(): void {
// grab input elements after they apepar on UI
this.usernameInput = document.getElementById('username') as HTMLInputElement;
this.passwordInput = document.getElementById('password') as HTMLInputElement;
}

handleSubmit(event: Event) {

// Prevent form to reload on submit
event.preventDefault();

// Manipulate form data
const submitData = {
username: this.usernameInput.value,
password: this.passwordInput.value
}

console.log('submitData :>> ', submitData);
// { username: "PezoInTheHouse", password: ****** }
}

No sweat!
This approach works just fine for a super simple form. However, in reality, the forms we build are far more complex.

Challenge: Form validations

  • TypeScript Component
  usernameErrorMessage = ''
passowrdErrorMessage = ''

handleSubmit(event: Event) {

// Prevent form to reload on submit
event.preventDefault();

// Validate username
if (!this.usernameInput.value) {
this.usernameErrorMessage = 'Username is required!';
return;
}

if (this.usernameInput.value.length > 20) {
this.usernameErrorMessage = 'Username is too long!';
return;
}

// Validate password
if (!this.passwordInput.value) {
this.passowrdErrorMessage = 'Password is required!';
return;
}

if (this.passwordInput.value.length > 20) {
this.passowrdErrorMessage = 'Password is too long!';
return;
}

// Manipulate form data
const submitData = {
username: this.usernameInput.value,
password: this.passwordInput.value
}

console.log('submitData :>> ', submitData);
}
  • HTML Template
<div class="form-wrapper">

<h1>Sign up form example</h1>

<form (submit)="handleSubmit($event)">

<input type="text" placeholder="username" id="username" class="form-field-input" />
@if (!!usernameErrorMessage) {
<p class="error-text">{{ usernameErrorMessage }}</p>
}

<input type="password" placeholder="password" id="password" class="form-field-input"/>
@if (!!passowrdErrorMessage) {
<p class="error-text">{{ passowrdErrorMessage }}</p>
}

<button type="submit" class="button-primary">Sign up</button>
</form>

</div>

It works. However, today, you’ll rarely find a form where you populate all the elements only to display an error for each field on submission.

A more intuitive way is to display an error when the input field enters an invalid state (after user interaction). This would be that we’d have to bind various events on inputs and track those changes manually:

<input type="text" placeholder="username" class="form-field-input"
(blur)="handleTouchEvent()" (change)="handleChange()"
[value]="latestValue"/>
  • We could have different validation rules, such as requiring a combination of letters, numbers, and special characters for the password field.
  • In addition, the username field could interact with an API to check if that username already exists.
  • Also, we have to consider performance. We do not want to validate text or call an API on every key press; We should apply throttling.
  • If we have a password and the repeat password fields, we need to have some validation where the two fields must have the same value.

These small requirements make writing all of this manually a living nightmare. We would have to check all the validation rules between fields manually. Listen to DOM events to determine what was triggered and when, etc.

There has to be a better way to handle this.
The Reactive Forms are the answer.

Getting Started with Reactive Forms

Now let’s see how Reactive forms compare:

Step 1

Import the ReactiveFormsModule into your AppModule:

import { ReactiveFormsModule } from '@angular/forms';

@NgModule({
declarations: [AppComponent],
bootstrap: [AppComponent],
imports: [
BrowserModule,
AppRoutingModule,
ReactiveFormsModule, // <-- Here it is
],
providers: [],
})
export class AppModule {}

If you’re using Standalone components, then you import the module into the imports array:

import { Component } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';

@Component({
selector: 'app-root',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule // <-- Here it is
],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
})
export class AppComponent

Step 2

Reactive forms follow the model-driven form-creation approach, where you create a form model, controls, and validations in the TypeScript component and bind them to the template.
This is done using the FormBuilder class in Angular, which is injected by adding it to the component constructor.

constructor(private fb: FormBuilder) {}

The form is a FormGroup that consists of multiple FormControls (one for each form field) or even nested forms FormGroups.

signUpForm!: FormGroup;

this.signUpForm = this.fb.group({
username: [''],
password: [''],
});

Each form field is a FormControl that is added upon form creation. That said, form controls and groups can be added or removed later via FormArrays, which we will learn more about in the second part.

Full example:

import { FormBuilder, FormGroup } from '@angular/forms';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent implements OnInit {

signUpForm!: FormGroup;

constructor(private fb: FormBuilder) {}

ngOnInit(): void {
this.signUpForm = this.fb.group({ // Form Group
username: [''], // Form Control
password: [''], // Form Control
});
}

handleSubmit() {
const formValue = this.signUpForm.value;

console.log('formValue :>> ', formValue);
}

Step 3

Bind the TypeScript component to the template:

  • Bind Form Group to the form HTML element: ([formGroup]=”signUpForm”)
  • Bind each Form Control to the input elements:
    (formControlName=”username”, formControlName=”password”)
  • Bind the submit event to the method created in the component:
    ((submit)=”handleSubmit()”)
<div class="form-wrapper">

<h1>Sign up form example</h1>

<form (submit)="handleSubmit()" [formGroup]="signUpForm">

<input type="text" placeholder="username" formControlName="username" class="form-field-input" />
<input type="password" placeholder="password" formControlName="password" class="form-field-input"/>

<button type="submit" class="button-primary">Sign up</button>
</form>

</div>

Once the form model is created and is wired with the template, any changes made to the form controls are automatically reflected in the form’s state, without the need for explicit event handling.

We also do not worry about preventing form reload on submit because Angular handles that for us.

Form Validations

Let’s expand the example with form validations. The Reactive forms include a set of validator functions for common use cases:

  • Required fields
  • Minimum / Maximum length (number of characters)
  • Minimum / Maximum value (for numbers)
  • Pattern match (using RegEx)

An example of adding the required validator to the controls:

import { FormBuilder, FormGroup, Validators } from '@angular/forms';

ngOnInit(): void {
this.signUpForm = this.fb.group({
username: ['', Validators.required],
password: [''],
});
}

We can check the validity status by using the valid property on the FromGroup:

handleSubmit() {

const isFormValid = this.signUpForm.valid;
console.log('isFormValid :>> ', isFormValid);

if (!isFormValid) {
return;
}

const formValue = this.signUpForm.value;
console.log('formValue :>> ', formValue);
}

Clicking the submit button before adding a username will make the form invalid. Otherwise, the form status will be valid when the username is present.

The validity can be confirmed using the username FormControl. The first thing we need to do is extract the username control from the form, which is done using the get() property on the FormGroup:

import {
AbstractControl,
FormBuilder,
FormGroup,
Validators,
} from '@angular/forms';

// Retrieve 'username' control
const usernameControl = this.signUpForm.get('username') as AbstractControl;

Now, let’s inspect the value and validity of the username control:

handleSubmit() {

const usernameControl = this.signUpForm.get('username') as AbstractControl;

console.log(usernameControl.value); // control value
console.log(usernameControl.valid); // false when empty, true when not
}

Expanding the controls & validations

Each FormControl can have a set of sync and async validators. They can also have a default value that appears in the field.

this.fb.group({
username: [
'DEFAULT VALUE',
[
/* Sync validators */
],
[
/* Async validators */
],
],
...
});

Now apply this to the signup form:

ngOnInit(): void {
this.signUpForm = this.fb.group({
username: ['', [Validators.required, Validators.minLength(3), Validators.maxLength(24)]],
password: ['', [Validators.required, Validators.minLength(10)]],
age: ['', [Validators.min(12), Validators.pattern("^[0-9]*$")]], // numbers only
rememberMe: [true]
});
}

This translates to:

  • The username control is mandatory and requires a minimum length of 3 and a maximum of 24 characters.
  • The password control is also mandatory and requires a minimum length of 10 characters.
  • The age control is optional but accepts only numbers
  • The rememberMe control is also optional but is checked by default (true).

Form-specific classes in the HTML template

Let’s update the template with new controls:

<div class="form-wrapper">

<h1>Sign up form example</h1>

<form (submit)="handleSubmit()" [formGroup]="signUpForm">

<input type="text" placeholder="username" formControlName="username" class="form-field-input" />
<input type="password" placeholder="password" formControlName="password" class="form-field-input"/>
<input type="number" placeholder="age" formControlName="age" class="form-field-input"/>
<div>
<input type="checkbox" formControlName="rememberMe" /> Remember me?
</div>

<button type="submit" class="button-primary">Sign up</button>
</form>

</div>

The controls set in the component are also evident in the inspect element view:

As seen in the dev tools, the controls username and password have the
ng-invalid class, meaning they are in an invalid state, which makes the whole form invalid.

This can be verified by adding the invalid validation checks in the template,

<input type="text" placeholder="username" formControlName="username" class="form-field-input" />
@if (signUpForm.get('username')?.invalid) {
<p class="error-text">Username is mandatory!</p>
}

<input type="password" placeholder="password" formControlName="password" class="form-field-input"/>
@if (signUpForm.get('password')?.invalid) {
<p class="error-text">Password is mandatory!</p>
}

The errors appear as soon as the page loads.

This is not a good user experience. It would be much better if the errors appeared after the user interacts with the fields. Let’s look at the inspect element view again to figure out what's happening.

When the page loads, every element tied to the form control has a set of Angular form classes:

  • ng-untouched
  • ng-pristine

These indicate that the form fields are untouched, nor is anything written on them. The polar opposite would be:

  • ng-touched (The user touched/clicked the field)
  • ng-dirty (The user typed on the form field)

After interacting with the fields, the errors are gone and the new classes appear:

What does this tell us?

Instead of displaying the errors as soon as the page loads, we can make them appear only after the user has interacted with the form by using the touched and dirty events on the form controls.

<input type="text" placeholder="username" formControlName="username" class="form-field-input" />
@if (signUpForm.get('username')?.invalid &&
(signUpForm.get('username')?.dirty || signUpForm.get('username')?.touched))
{
<p class="error-text">Username is mandatory!</p>
}

<input type="password" placeholder="password" formControlName="password" class="form-field-input"/>
@if (signUpForm.get('password')?.invalid &&
(signUpForm.get('password')?.dirty || signUpForm.get('password')?.touched))
{
<p class="error-text">Password is mandatory!</p>
}

This tells Angular to display the error for each field only when:

  • the field is invalid and
  • is either touched or typed on

And we can verify that on the page as well.

The errors will appear as soon as you click on the field and click away (the class changes from ng-untouched to ng-touched), which is what we expect:

Testing different validation types

So far, we’ve only covered what happens when the form is required. Other validation rules can be verified the same way, but before doing that, I’d like to make an adjustment to how we’re inspecting the errors in the code.

So far, we’ve manually fetched the control in the template and then checked the validations. We can improve this by the boilerplate to the getter function in the Typescript class:

export class AppComponent implements OnInit {

signUpForm!: FormGroup;

constructor(private fb: FormBuilder) {}

ngOnInit(): void {
this.signUpForm = this.fb.group({
...
});
}

// username control getter:
get usernameControl(): AbstractControl {
return this.signUpForm.get('username') as AbstractControl;
}

}

What the getter function does is simplify the way we reach the control. Instead of constantly calling this.signUpForm.get(‘username’) to check if the username control is valid, touched or read the value of the control, etc., we can just call the usernameControl getter method.

<input type="text" placeholder="username" formControlName="username" class="form-field-input" />

@if (usernameControl.invalid && (usernameControl.dirty || usernameControl.touched))
{
<p class="error-text">Username is mandatory!</p>
}

Looks much better.
Now, we’ll make use of the hasError() method of the FormControl to check for each control validation, we set in the Typescript class:

  • Validators.required => usernameControl.hasError(‘required’)
  • Validators.minLength(3) => usernameControl.hasError(‘minlength’)
  • Validators.maxLength(24) => usernameControl.hasError('maxlength')
<input type="text" placeholder="username" formControlName="username" class="form-field-input" />
@if (usernameControl.invalid && (usernameControl.dirty || usernameControl.touched))
{
@if (usernameControl.hasError('required')) {
<p class="error-text">Username is mandatory!</p>
} @else if (usernameControl.hasError('maxlength')) {
<p class="error-text">Username is too long!</p>
} @else {
<p class="error-text">Username is too short!</p>
}
}

When the control enters the valid state, the errors will be gone.

The getters can be applied to other form controls as well:

ngOnInit(): void {
this.signUpForm = this.fb.group({
...
});
}

get usernameControl(): AbstractControl {
return this.signUpForm.get('username') as AbstractControl;
}

get passwordControl(): AbstractControl {
return this.signUpForm.get('password') as AbstractControl;
}

get ageControl(): AbstractControl {
return this.signUpForm.get('age') as AbstractControl;
}

get rememberMeControl(): AbstractControl {
return this.signUpForm.get('rememberMe') as AbstractControl;
}

The age field is optional by default. That’s why we do not need the required validator here. However, we can check the minimum number and the pattern validator:

<input type="text" placeholder="age" formControlName="age" class="form-field-input"/>
@if (ageControl.invalid && (ageControl.dirty || ageControl.touched))
{
@if (ageControl.hasError('min')) {
<p class="error-text">You have to be at least 12 years old!</p>
} @else if (ageControl.hasError('pattern')) {
<p class="error-text">Only numbers allowed!</p>
}
}

I deliberately changed the input type from “number” to “text” so the pattern error could appear.

Async validations

The Async validations are applied when verifying the form data against the backend API.

To make this work, I created a simple Express.js server. The server exposes an API that checks if the provided username is in the existing users list.

const express = require('express');
const app = express();
const cors = require('cors');

app.use(express.json())
app.use(cors());

const users = ['Mystic', 'Phantom', 'Twingi', 'MornarPopaj'];

app.get('/api/user/:username', (req, res) => {
const { username } = req.params;

const isExistingUser = !!users.find(user => user === username);

res.status(200).send({ isExistingUser })
});

app.listen(3000, () => console.log('Server stared!');

If there is a match, the API returns true. Otherwise, it returns false.

Testing the API

Angular Service

We need to create an Angular service that will call this API:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';

export interface ICheckExistingUser {
isExistingUser: boolean;
}

@Injectable({
providedIn: 'root'
})
export class UsersService {

constructor(private http: HttpClient) {}

checkIfUserExists(username: string): Observable<ICheckExistingUser> {
return this.http.get<ICheckExistingUser>(`http://localhost:3000/api/user/${username}`);
}
}

Custom Validator

The next step is creating a custom form validator invoking this service.

import { Injectable } from '@angular/core';
import { ICheckExistingUser, UsersService } from './features/core/services/users.service';
import {
AbstractControl,
AsyncValidatorFn,
ValidationErrors,
} from '@angular/forms';
import { Observable, catchError, map, of } from 'rxjs';

@Injectable({
providedIn: 'root',
})
export class UsersValidator {
constructor(private usersService: UsersService) {}

userExistsValidator(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value) {
return of(null);
// Return null if username field is empty
}

return this.usersService
.checkIfUserExists(control.value)
.pipe(
map((data: ICheckExistingUser) =>
data.isExistingUser ? { isExistingUser: true } : null),
catchError(() => of(null))
// Return null if any error occurs
);
};
}
}

What this validator does is call the API, and if the response is true (username already exists), it will return { isExistingUser: true }. Otherwise, it returns null.

Custom Validator in the Component

Here, we apply the custom validator to the username control:

constructor(
private fb: FormBuilder,
private readonly usersValidator: UsersValidator
) {}

ngOnInit(): void {
this.signUpForm = this.fb.group({
username: [
'',
[Validators.required, Validators.minLength(3), Validators.maxLength(24)],
[this.usersValidator.userExistsValidator()] // async validator
],
password: ['', [Validators.required, Validators.minLength(10)]],
age: ['', [Validators.min(12), Validators.pattern("^[0-9]*$")]],
rememberMe: [true]
});
}

Then, all we have to do is validate if the control has isExistingUser error.

  • HTML Template
<input type="text" placeholder="username" formControlName="username" class="form-field-input" />
@if (usernameControl.invalid && (usernameControl.dirty || usernameControl.touched))
{
@if (usernameControl.hasError('required')) {
<p class="error-text">Username is mandatory!</p>
} @else if (usernameControl.hasError('maxlength')) {
<p class="error-text">Username is too long!</p>
} @else if (usernameControl.hasError('minlength')) {
<p class="error-text">Username is too short!</p>

<!-- Async validator 👇 -->
} @else if (usernameControl.hasError('isExistingUser')) {
<p class="error-text">Username is already taken!</p>
}
}

Using Observables with Reactive Forms

There is a slight performance downside to using the Async validator in this manner — We’re calling an API on each keypress.

Yikes!
A better way to handle this is by applying these rules:

  • Don’t invoke an API until the user stops typing (wait for around 300ms)
  • Cancel the existing search request if the new search is emitted.

This is where Rx.js Observables can help.
Reactive Forms in Angular heavily utilize Observables. Each form control is an Observable stream of values you can operate on (using Rx.js operators).

The valueChanges property takes the form control's value and transforms it into the Observable you can subscribe to.

import { debounceTime, distinctUntilChanged } from 'rxjs';

ngOnInit(): void {
this.signUpForm = this.fb.group({
username: ['', [Validators.required, Validators.minLength(3), Validators.maxLength(24)]],
password: ['', [Validators.required, Validators.minLength(10)]],
age: ['', [Validators.min(12), Validators.pattern("^[0-9]*$")]],
rememberMe: [true]
});
}
//
this.signUpForm.get('username')?.valueChanges
.pipe(
// emit 500ms after user stopped typing
debounceTime(500),
// emit only when value changes
distinctUntilChanged()
)
.subscribe((data: string) => {
console.log('data :>> ', data);
})
}

Reduce API calls using Observables

Now, go back to the custom validator and apply the throttling:

import { Observable, catchError, map, of, switchMap, timer } from 'rxjs';

@Injectable({
providedIn: 'root',
})
export class UsersValidator {
constructor(private usersService: UsersService) {}

userExistsValidator(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value) {
return of(null);
}

// Start a 300ms timer before calling the API
return timer(300).pipe(

// Switch to API call after debounce
switchMap(() => this.usersService.checkIfUserExists(control.value)),

// Check the validity
map((response: ICheckExistingUser) => (response.isExistingUser ? { isExistingUser: true } : null)),

// Handle an error if any
catchError(() => of(null))
);
};
}
}

With this in place, the API call will trigger 300ms after the user stops typing.

Peeking into different Form properties

Here are some of the properties and methods you can use to check the state or alter the behavior of the form or any of its controls.

Inspect form

  • Read form value
this.signUpForm.value
  • Check the form validity:
this.signUpForm.valid // true / false
  • Inspect form interactions
this.signUpForm.status // INVALID / VALID
this.signUpForm.dirty // true / false
this.signUpForm.touched // true / false
this.signUpForm.pristine // true / false
this.signUpForm.untouched// true / false
  • Look up form controls
this.signUpForm.get('username') // Abstract Control
this.signUpForm.get('password') // Abstract Control

Alter Form Behavior

  • Clear form values
this.signUpForm.reset();
  • Clear errors
this.signUpForm.setErrors(null);
  • Mark as dirty
this.signUpForm.markAsDirty();
  • Mark as touched
this.signUpForm.markAsTouched();
  • Update validity
this.signUpForm.updateValueAndValidity();

Using the same methods, you can inspect or alter individual controls:

this.signUpForm.get('username')?.value;
this.signUpForm.get('username')?.valid;
this.signUpForm.get('username')?.touched;
this.signUpForm.get('username')?.dirty;
this.signUpForm.get('username')?.markAsDirty();
this.signUpForm.get('username')?.patchValue('New value');
this.signUpForm.get('username')?.setErrors(null);
this.signUpForm.get('username')?.hasError('required') // true / false
// and so on...

Wrapping up

The Angular Reactive Forms library represents a powerful and intuitive way of creating and manipulating forms.
This was just the tip of the iceberg. In the next chapter, we’ll dive into Form Arrays, Custom form controls, and using Reactive forms with Angular Material.

Bye for now 👋

--

--

Mirza Leka

Web Developer. DevOps Enthusiast. I share my experience with the rest of the world. Follow me on https://twitter.com/mirzaleka for news & updates #FreePalestine