DEV Community

Cover image for Simplifying form error validations in Angular.
Justin
Justin

Posted on • Originally published at Medium

Simplifying form error validations in Angular.

This solution is heavenly inspired by Netanel Basel with "Make your Angular forms error messages magically appear". Definitely recommend to check out this article aswell.

When it comes to handling error validations in Angular forms, the Angular documentation provides an example that involves manually checking each error condition and rendering the corresponding error message using *ngIf directives. While this approach works, it can quickly become cumbersome and repetitive. In this article, we'll explore a more streamlined and reusable solution.

The Angular documentation offers the following example, which showcases a single input. Now, imagine a scenario where multiple forms and inputs are utilized.

<input type="text" id="name" class="form-control"
      formControlName="name" required>

<div *ngIf="name.invalid && (name.dirty || name.touched)"
    class="alert alert-danger">

  <div *ngIf="name.errors?.['required']">
    Name is required.
  </div>
  <div *ngIf="name.errors?.['minlength']">
    Name must be at least 4 characters long.
  </div>
  <div *ngIf="name.errors?.['forbiddenName']">
    Name cannot be Bob.
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Consider the possibility of simplifying this by adopting the approach outlined below.

<input type="text" id="name" class="form-control"
      formControlName="name" required>
<control-error controlName="name" />
Enter fullscreen mode Exit fullscreen mode

By leveraging Angular directives and a custom ControlErrorComponent, we can simplify the error validation process and remove repetitive code.

The ControlErrorComponent encapsulates the error validation logic. This component receives the form control name as an input and utilizes the FormGroupDirective to access the corresponding form control. Let's start with creating a basic ControlErrorComponent.

@Component({
  standalone: true,
  selector: 'control-error',
  imports: [AsyncPipe],
  template: '<div class="alert alert-danger">{{ message$ | async }}</div>',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ControlErrorComponent implements OnInit {
  private formGroupDirective = inject(FormGroupDirective);
  message$ = new BehaviorSubject<string>('');

  @Input() controlName!: string;

  ngOnInit(): void {
    if (this.formGroupDirective) {
      // Access the corresponding form control
      const control = this.formGroupDirective.control.get(this.controlName);

      if (control) {
        this.setError('Field required');
      }
    }
  }

  setError(text: string) {
    this.message$.next(text);
  }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the error text is shown right away, lacks reactivity and dynamic behavior. The error is hardcoded, which limits its flexibility. To improve this, we can utilize an error configuration object that maps error keys to specific error messages. By providing a central configuration, we decouple the error handling logic from the template code and allow for easy customization and localization of error messages.

const defaultErrors: {
  [key: string]: any;
} = {
  required: () => `This field is required`,
  minlength: ({ requiredLength, actualLength }: any) => `Name must be at least ${requiredLength} characters long.`,
  forbiddenName: () => 'Name cannot be Bob.',
};

export const FORM_ERRORS = new InjectionToken('FORM_ERRORS', {
  providedIn: 'root',
  factory: () => defaultErrors,
});
Enter fullscreen mode Exit fullscreen mode

Let's proceed with updating the component to leverage the centralized error configuration.

export class ControlErrorComponent implements OnInit, OnDestroy {
  private subscription = new Subscription();
  private formGroupDirective = inject(FormGroupDirective);
  errors = inject(FORM_ERRORS);
  message$ = new BehaviorSubject<string>('');

  @Input() controlName!: string;

  ngOnInit(): void {
    if (this.formGroupDirective) {
      const control = this.formGroupDirective.control.get(this.controlName);

      if (control) {
        this.subscription = merge(control.valueChanges)
          .subscribe(() => {
            const controlErrors = control.errors;

            if (controlErrors) {
              const firstKey = Object.keys(controlErrors)[0];
              const getError = this.errors[firstKey];
              // Get message from the configuration
              const text = getError(controlErrors[firstKey]);

              // Set the error based on the configuration
              this.setError(text);
            } else {
              this.setError('');
            }
          });
      }
    } 
  }

  ...
}
Enter fullscreen mode Exit fullscreen mode

You may notice that the error messages are only triggered when modifying the text within the input field, or when a required field is added and afterwards removed.

Form with First name showing the error “This field is required”. Field Last name is showing the error “Expected 4 but got 2”.

The absence of error messages upon clicking the "Sign In" button is because of the current implementation, which only listens to changes in the input values. To address this, we can enhance the error handling by utilizing the FormGroupDirective to capture the ngSubmit event. The error messages will now also be triggered on a button click.

this.subscription = merge(control.valueChanges, this.formGroupDirective.ngSubmit)
Enter fullscreen mode Exit fullscreen mode

Additionally, to provide flexibility in modifying the default required error message, we can introduce a customErrors property. This property can be utilized as follows:

<control-error controlName="name" [customErrors]="{ required: 'This could be a custom required error'}" />
Enter fullscreen mode Exit fullscreen mode

Add the input element and update the text variable to make use of the of custom errors aswell.

@Input() customErrors?: ValidationErrors;

ngOnInit..
const text = this.customErrors?.[firstKey] || getError(controlErrors[firstKey]);
Enter fullscreen mode Exit fullscreen mode

Form with first name showing the error “This field is required”. Last name showing the error “This could be a custom required error”.

Voilà! You now have your very own custom form validation error component. This component is designed to work seamlessly within nested components and offers great flexibility. You can place this component directly below or above the input field, or wherever you want. Below is a complete code example for your reference:

@Component({
  standalone: true,
  selector: 'control-error',
  imports: [AsyncPipe],
  template: '<div class="alert alert-danger">{{ message$ | async }}</div>',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ControlErrorComponent implements OnInit, OnDestroy {
  private subscription = new Subscription();
  private formGroupDirective = inject(FormGroupDirective);
  errors = inject(FORM_ERRORS);
  message$ = new BehaviorSubject<string>('');

  @Input() controlName!: string;
  @Input() customErrors?: ValidationErrors;

  ngOnInit(): void {
    if (this.formGroupDirective) {
      const control = this.formGroupDirective.control.get(this.controlName);

      if (control) {
        this.subscription = merge(control.valueChanges, this.formGroupDirective.ngSubmit)
          .pipe(distinctUntilChanged())
          .subscribe(() => {
            const controlErrors = control.errors;

            if (controlErrors) {
              const firstKey = Object.keys(controlErrors)[0];
              const getError = this.errors[firstKey];
              const text = this.customErrors?.[firstKey] || getError(controlErrors[firstKey]);

              this.setError(text);
            } else {
              this.setError('');
            }
          });
      } else {
        const message = this.controlName
          ? `Control "${this.controlName}" not found in the form group.`
          : `Input controlName is required`;
        console.error(message);
      }
    } else {
      console.error(`ErrorComponent must be used within a FormGroupDirective.`);
    }
  }

  setError(text: string) {
    this.message$.next(text);
  }

  ngOnDestroy(): void {
    if (this.subscription) {
      this.subscription.unsubscribe();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Thank you for reading!

Top comments (0)