Official Documentation View Source on Github

Highlights

  1. Define the validator by implementing AsyncValidator.
  2. The validator is debounced with delay to avoid doing too many HTTP requests.
  3. Set the custom validators with FormBuilder.
  4. Access the validation errors through the field getter.
  5. Access the state of the validation through ngModel.pending

// app/async-validation/user.ts

export interface User {
  id: number;
  username: string;
  password: string;
}
// app/async-validation/unique-username-validator.ts

import { Injectable } from '@angular/core';
import {
  AbstractControl,
  AsyncValidator,
  ValidationErrors,
} from '@angular/forms';
import { Observable, of } from 'rxjs';
import { delay, filter, map, switchMap } from 'rxjs/operators';
import { UserService } from './user.service';

// For more context see: https://stackoverflow.com/a/62662296
@Injectable({
  providedIn: 'root',
})
export class UniqueUsernameValidator implements AsyncValidator {
  constructor(private readonly userService: UserService) {}

  validate(
    control: AbstractControl
  ): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> {
    return of(control.value).pipe(
      filter((username) => !!username),
      delay(500),
      switchMap((username) =>
        this.userService
          .isUsernameAvailable(username)
          .pipe(
            map((available) =>
              available ? null : { uniqueUsername: { value: control.value } }
            )
          )
      )
    );
  }
}
<!-- app/async-validation/sign-up-form-ngxs-plugin/sign-up-form-ngxs-plugin.component.html -->

<h1>Sign Up Form (NGXS with form-plugin)</h1>

<mat-card>
  <form
    ngxsForm="signUpFormNgxsPlugin.signUpForm"
    [formGroup]="signUpForm"
    (ngSubmit)="submit()"
  >
    <mat-form-field>
      <mat-label>Username</mat-label>
      <input matInput name="username" formControlName="username" />
      <mat-spinner
        matSuffix
        [diameter]="18"
        *ngIf="username?.pending"
      ></mat-spinner>
      <mat-error *ngIf="username?.errors?.required"
        >You must enter a value</mat-error
      >
      <mat-error *ngIf="username?.errors?.uniqueUsername"
        >The username has already been taken</mat-error
      >
    </mat-form-field>
    <mat-form-field>
      <mat-label>Password</mat-label>
      <input
        matInput
        type="password"
        name="password"
        formControlName="password"
      />
      <mat-error *ngIf="password?.errors?.required"
        >You must enter a value</mat-error
      >
    </mat-form-field>
    <div class="form-buttons">
      <button
        mat-raised-button
        type="submit"
        color="primary"
        [disabled]="(loading$ | async) || !signUpForm.valid"
      >
        Submit
      </button>
    </div>
  </form>
</mat-card>

<h2>User (JSON)</h2>

<pre
  >{{ user$ | async | json }}
</pre>

<h2>Form state (JSON)</h2>

<pre
  >{{ signUpFormState$ | async | json }}
</pre>
// app/async-validation/sign-up-form-ngxs-plugin/sign-up-form-ngxs-plugin.component.ts

import { Component } from '@angular/core';
import { FormBuilder, FormControl, Validators } from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Select, Store } from '@ngxs/store';
import { Observable } from 'rxjs';
import { withLatestFrom } from 'rxjs/operators';
import { UniqueUsernameValidator } from '../unique-username-validator';
import { User } from '../user';
import { SignUpFormNgxsPlugin } from './sign-up-form-ngxs-plugin.actions';
import { SignUpForm } from './sign-up-form-ngxs-plugin.state';
import { SignUpFormNgxsPluginSelectors } from './sign-up-form-ngxs-plugin.selectors';

@Component({
  selector: 'app-sign-up-form-ngxs-plugin',
  templateUrl: './sign-up-form-ngxs-plugin.component.html',
})
export class SignUpFormNgxsPluginComponent {
  readonly signUpForm = this.formBuilder.group({
    username: ['', Validators.required, [this.uniqueUsernameValidator]],
    password: ['', Validators.required],
  });
  loading = false;

  @Select(SignUpFormNgxsPluginSelectors.form)
  signUpFormState$!: Observable<SignUpForm>;

  @Select(SignUpFormNgxsPluginSelectors.model)
  user$!: Observable<User>;

  @Select(SignUpFormNgxsPluginSelectors.loading)
  loading$!: Observable<boolean>;

  get username() {
    return this.signUpForm.get('username');
  }

  get password() {
    return this.signUpForm.get('password');
  }

  constructor(
    private readonly formBuilder: FormBuilder,
    private readonly uniqueUsernameValidator: UniqueUsernameValidator,
    private readonly store: Store,
    private readonly snackBar: MatSnackBar
  ) {}

  submit(): void {
    this.store
      .dispatch(new SignUpFormNgxsPlugin.Submit())
      .pipe(withLatestFrom(this.user$))
      .subscribe(([, { id }]) => {
        this.snackBar.open(`User saved with ID #${id}`, 'Close');
      });
  }
}
// app/async-validation/user.service.ts

import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { User } from './user';

export interface UserSaveResponse {
  id: number;
}

@Injectable({
  providedIn: 'root',
})
export class UserService {
  isUsernameAvailable(username: string): Observable<boolean> {
    console.log('Is username available?', username);
    // Let's say that all usernames with a dot are taken
    return of(username.indexOf('.') === -1 ? true : false).pipe(delay(1000));
  }

  save(user: User): Observable<UserSaveResponse> {
    console.log('Save:', user);
    return of({
      id: 1,
    }).pipe(delay(1000));
  }
}
// app/async-validation/sign-up-form-ngxs-plugin/sign-up-form-ngxs-plugin.state.ts

import { Injectable } from '@angular/core';
import { Action, State, StateContext } from '@ngxs/store';
import { finalize, tap } from 'rxjs/operators';
import { User } from '../user';
import { UserService } from '../user.service';
import { SignUpFormNgxsPlugin } from './sign-up-form-ngxs-plugin.actions';

export interface SignUpForm {
  model: User;
  status: string;
  dirty: boolean;
}

export interface SignUpFormNgxsPluginStateModel {
  signUpForm: SignUpForm;
  loading: boolean;
}

@State<SignUpFormNgxsPluginStateModel>({
  name: 'signUpFormNgxsPlugin',
  defaults: {
    signUpForm: {
      model: {
        id: 0,
        username: '',
        password: '',
      },
      status: 'INVALID',
      dirty: false,
    },
    loading: false,
  },
})
@Injectable()
export class SignUpFormNgxsPluginState {
  constructor(private readonly userService: UserService) {}

  @Action(SignUpFormNgxsPlugin.Submit)
  submit(context: StateContext<SignUpFormNgxsPluginStateModel>) {
    const state = context.getState();
    context.patchState({
      loading: true,
    });
    return this.userService.save(state.signUpForm.model).pipe(
      tap(({ id }) =>
        context.patchState({
          signUpForm: {
            ...state.signUpForm,
            model: {
              ...state.signUpForm.model,
              id,
            },
          },
        })
      ),
      finalize(() =>
        context.patchState({
          loading: false,
        })
      )
    );
  }
}
// app/async-validation/sign-up-form-ngxs-plugin/sign-up-form-ngxs-plugin.actions.ts

export namespace SignUpFormNgxsPlugin {
  export class Submit {
    static readonly type = '[SignUpFormNgxsPlugin] Submit';
  }
}
// app/async-validation/sign-up-form-ngxs-plugin/sign-up-form-ngxs-plugin.selectors.ts

import { Selector } from '@ngxs/store';
import { User } from '../user';
import {
  SignUpForm,
  SignUpFormNgxsPluginState,
  SignUpFormNgxsPluginStateModel,
} from './sign-up-form-ngxs-plugin.state';

export class SignUpFormNgxsPluginSelectors {
  @Selector([SignUpFormNgxsPluginState])
  static form({ signUpForm }: SignUpFormNgxsPluginStateModel): SignUpForm {
    return signUpForm;
  }

  @Selector([SignUpFormNgxsPluginSelectors.form])
  static model({ model }: SignUpForm): User {
    return model;
  }

  @Selector([SignUpFormNgxsPluginState])
  static loading({ loading }: SignUpFormNgxsPluginStateModel): boolean {
    return loading;
  }
}