Search code examples
angulartypescriptobservabledelayng-class

How to manage component status with observables in Angular


Context

In an Angular project, I have made a component to encapsulate the logic of a submit request page (code below).

This page has two subcomponents that define specific forms (firstForm and secondForm) and a submit button.

Each subcomponent has an input for the disabled state

@Input() set disabled(value: boolean | null) {
    if (value === null) {
      value = false;
    }
    this._disabled = value;
    this._onDisabledChange();
  }

and an output to emit if the form is valid or not:

@Output() onValidChange: EventEmitter<boolean> = new EventEmitter();

I want that to enable the following form, the previous ones have to be valid. These would be the enabled dependencies:

  • first-form -(depends-on)-> no one

  • second-form -(depends-on)-> first-form

  • submit button -(depends-on)-> first-form & second-form

Problem

When first-form and second-form are completed (submit enabled), and I delete one of first-form's required input (so first-form is invalid), this happens:

  • second-form updates to disabled (correct)
  • submit doesn't update to disabled style until I click outside the input (incorrect)

I would like that submit disabled style updated instantly.

Note:

I am using changeDetection: ChangeDetectionStrategy.OnPush. As a test I used Default strategy, and the console showed the error ExpressionChangedAfterItHasBeenCheckedError.

Code

As this is a complex problem, I have simplified the code, leaving only the relevant parts.

Components have another EventEmitter to emit the form values, but here I want to focus on the valid state.

Html template

<div>
  <first-form (onValidChange)="firstFormValid = $event"></first-form>

  <second-form
    [disabled]="!(isSecondStepEnabled$ | async)"
    (onValidChange)="secondFormValid = $event"
  ></second-form>

  <div [ngClass]="{ disabled: !(isSubmitStepEnabled$ | async) }">
    <ul>
      <li>
        <button
          id="submit"
          [disabled]="!(isSubmitStepEnabled$ | async)"
          (click)="submit()"
        >
          Submit
        </button>
      </li>
    </ul>
  </div>
</div>

Angular Component

export class PageComponent {

  private _isFirstFormValid$: BehaviorSubject<boolean> = new BehaviorSubject<
    boolean
  >(false);

  private _isSecondFormValid$: BehaviorSubject<boolean> = new BehaviorSubject<
    boolean
  >(false);

  set firstFormValid(valid: boolean) {
    this._isFirstFormValid$.next(valid);
  }

  set secondFormValid(valid: boolean) {
    this._isSecondFormValid$.next(valid);
  }

  get isSecondFormValid$(): Observable<boolean> {
    return this._isSecondFormValid$.asObservable();
  }

  isFirstStepCompleted$: Observable<
    boolean
  > = this._isFirstFormValid$.asObservable();

  isSecondStepEnabled$: Observable<
    boolean
  > = this._isFirstFormValid$.asObservable();

  isSecondStepCompleted$: Observable<boolean> = combineLatest([
    this.isSecondStepEnabled$,
    this.isSecondFormValid$
  ]).pipe(map(this._areAllTrue()));

  isSubmitStepEnabled$: Observable<boolean> = combineLatest([
    this.isFirstStepCompleted$,
    this.isSecondStepCompleted$
  ]).pipe(map(this._areAllTrue()));

  private _areAllTrue(): (value: boolean[]) => boolean {
    return (values: boolean[]) => {
      return values.every(valid => valid === true);
    };
  }

  submit() {
      // call external service
  }
}

Version

Angular CLI: 9.0.4
Node: 13.6.0
OS: darwin x64
Angular: 9.0.4
rxjs: 6.5.4
typescript: 3.7.5
webpack: 4.41.6

Solution

The only solution I found was using delay(0) on the Observable isSubmitStepEnabled$.

  isSubmitStepEnabled$: Observable<boolean> = combineLatest([
    this.isFirstStepCompleted$,
    this.isSecondStepCompleted$
  ]).pipe(delay(0), map(this._areAllTrue()));

I think it is not a good solution and maybe I have a structure problem.

Thanks everyone!


Solution

  • Here's the solution I have found (without using delay function):

    Simply replace asObservable (when I want to get the stream from the behaviourSubject) with .pipe(shareReplay({ refCount: true, bufferSize: ONCE })).

    The difference between these two are:

    • with asObservable a new subscription is being created each time it is invoked (by a "listener").
    • with shareReplay, it's like you have one subscription and its data is broadcasted to all your "listeners"

    With asObservable, my code didn't run change detection until I clicked outside the input.