Reactive Form Validation in Angular - Do It Right

Reactive Form Validation in Angular - Do It Right

In this post, you will learn about reactive form validation in Angular.

In Angular, we often use reactive forms. I hope you are not using template-driven forms, as they are not scalable, are less convenient, and do not work with RxJS like reactive forms do. This is why in this post, we will focus on correctly validating reactive forms.

initial project

Here, I have prepared a small form with just two fields: "First name" and "Role".

export class AppComponent {
  fb = inject(NonNullableFormBuilder);
  form = this.fb.group({
    firstname: this.fb.control(''),
    role: this.fb.control(''),
  });

  onSubmit() {
    console.log(this.form.getRawValue());
  }
}

This is how it looks in AppComponent. First of all, I am using NonNullableFormBuilder to create a form where all fields can't be null, and a group to create our form.

<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <div>
    <input type="text" formControlName="firstname" placeholder="First name" />
  </div>

  <div>
    <input type="text" formControlName="role" placeholder="Role" />
  </div>
  <div><button type="submit">Submit</button></div>
</form>

Let's take a look at our HTML. This is just a normal reactive form with a formGroup and bound formControlName.

This is how we create our forms in reactive forms. I hope you already know how to do this. Now let's talk about validation.

Standard Validators

Angular provides many different validators out of the box to validate our fields.

form = this.fb.group({
  firstname: this.fb.control('', [Validators.required]),
  role: this.fb.control(''),
});

Here, we added a second parameter as an array with a required validator to our firstname. This is how people typically write them, but I don't like this approach.

form = this.fb.group({
  firstname: this.fb.control('', { validators: [Validators.required] }),
  role: this.fb.control(''),
});

I prefer to provide the second parameter as an object with a validators field. This is the same code, but if you want to apply asynchronous validators later, you can add an asyncValidators property. This way, you won't mix synchronous and asynchronous validators in the same array, which can be difficult to debug.

form = this.fb.group({
  firstname: this.fb.control('', {
    validators: [Validators.required, Validators.minLength(5)]
  }),
  role: this.fb.control(''),
});

Here, we added one more validator to check that the length of firstname is at least 5 characters.

Now the question is how to render these errors in our markup.

<div>
  <input type="text" formControlName="firstname" placeholder="First name" />
  <div *ngIf="form.controls.firstname.invalid && (form.controls.firstname.touched || form.controls.firstname.dirty)">
    <small *ngIf="form.controls.firstname.errors?.['required']">*First name is a required field</small>
    <small *ngIf="form.controls.firstname.errors?.['minlength']">*First name must be at least 5 characters long</small>
  </div>
</div>

We can access all errors inside form.controls.firstname.errors, but we only want to show them when the form is changed or the field is touched. This is why we put our errors inside additional *ngIf conditions.

error message

Now, when our form is touched, for example, we render an error indicating that the field is required. So our logic works correctly.

But when you write error messages, you might want to debug them. The easiest way is to render them as an object at the top of the field.

<form [formGroup]="form" (ngSubmit)="onSubmit()">
  =={{form.controls.firstname.errors | json}}==
  ...
</form>

required

By default, we see that there is a required error in the object. When we change our field, other errors will appear in the object.

Custom Validators in Angular

This is how you can use standard Angular validators in your forms, but sometimes it's not enough. You might need custom validators for your specific form. How can we do that?

export const forbiddenNameValidator = (control: AbstractControl): ValidationErrors | null => {
  const names = ['foo'];
  return names.includes(control.value) ? { forbiddenName: 'Name is not allowed' } : null;
};

export class AppComponent {
  form = this.fb.group({
    firstname: this.fb.control('', {
      validators: [
        Validators.required,
        Validators.minLength(5),
        forbiddenNameValidator
      ]
    }),
    role: this.fb.control(''),
  });
  ...
}

Here, we create a forbiddenNameValidator which gets a control as an argument and returns validation errors, which is either an object or null. Inside it, we can write any logic. Here, we check that the value of the control is not foo, which is not allowed. We must also add this custom validator to our array of validators.

custom

If we type "foo" inside our input, we get our custom error in the object, and we can render it on the screen.

<div *ngIf="form.controls.firstname.errors?.['forbiddenName']">
  <small>{{ form.controls.firstname.errors?.['forbiddenName'] }}</small>
</div>

So if there is such an error, we want to render its content on the screen.

But here is a problem: our forbiddenNameValidator is not configurable. We can't provide information from outside, which makes it inflexible. Let's change this.

export const forbiddenNameValidator = (names: string[]): ValidatorFn => {
  return (control: AbstractControl): ValidationErrors | null => {
    return names.includes(control.value)
      ? { forbiddenName: 'Name is not allowed' }
      : null;
  };
};

export class AppComponent {
  form = this.fb.group({
    firstname: this.fb.control('', {
      validators: [
        Validators.required,
        Validators.minLength(5),
        forbiddenNameValidator(['foo'])
      ]
    }),
    role: this.fb.control(''),
  });
  ...
}

Now, our forbiddenNameValidator returns a ValidatorFn and not errors. It returns a function, which allows us to get some information from outside. Here, we provided an array of forbidden names. At the place where we use it, we must provide this parameter.

As you can see, there are no changes on the screen, and it works in the same way.

Custom Asynchronous Validators in Angular

Now I want to show you how to create asynchronous custom validators, as normal validators are not always enough for complex forms.

You need an async validator when you want to make an API call to validate some field.

export const asyncRoleValidator = (
  control: AbstractControl,
): Observable<ValidationErrors | null> => {
  const allowedRoles = ['admin', 'dev'];
  return of(control.value).pipe(
    map((value) => {
      return allowedRoles.includes(value)
        ? null
        : { forbiddenRole: 'Role is not allowed' };
    }),
  );
};

export class AppComponent {
  form = this.fb.group({
    ...
    role: this.fb.control('', { asyncValidators: [asyncRoleValidator] }),
  });
  ...
}

To implement an async validator, we need to return an Observable of errors. This is the only difference. It is very similar to our custom validator. Inside, we can return any Observable or API call to get data back. Here, I wrote the same logic to check an allowed role but in an async way. Here, we get an error if the role is not allowed.

Most importantly, you must assign an async validator to the asyncValidators property, not to normal validators.

<div>
  <input type="text" formControlName="role" placeholder="Role" />
  <div *ngIf="form.controls.role.invalid && (form.controls.role.touched || form.controls.role.dirty)">
    <small *ngIf="form.controls.role.errors?.['forbiddenRole']">{{ form.controls.role.errors?.['forbiddenRole'] }}</small>
  </div>
</div>

There are no changes in how we render errors. We can access an error from the async validator and render it on the screen.

Want to sharpen your Angular skills and succeed in your next interview? Explore my Angular Interview Questions Course. This course is designed to help you build confidence, master challenging concepts, and be fully prepared for any coding interview.

📚 Source code of what we've done