How to deal with ExpressionChangedAfterItHasBeenCheckedError when subscribing to Observable using async pipe?

1.4k Views Asked by At

I have a simple loader mechanism based on ngRx/store:

actions

import { Action, createAction, props } from "@ngrx/store";

export const LoadingStartedAction = createAction("[Loading] Loading started", props<{ message: string }>());
export const LoadingEndedAction = createAction("[Loading] Loading ended");

reducer

export interface LoadingState {
  isLoading: boolean;
  loaderMessage: string;
}

export const initialLoadingState: LoadingState = {
  isLoading: false,
  loaderMessage: ""
};

export const loadingReducerInner = createReducer(
  initialLoadingState,
  on(LoadingStartedAction, (state) => {
      return {
        isLoading: true,
        loaderMessage: state.loaderMessage
      };
  }),
  on(LoadingEndedAction, _ => {
      return {
        isLoading: false,
        loaderMessage: ""
      };
  })
);

export function loadingReducer(state: LoadingState, action: Action) {
  return loadingReducerInner(state, action);
}

selectors

export const selectLoadingState = (state: AppState) => state.loading;

export const isLoadingSelector = createSelector(
  selectLoadingState,
  loading => loading.isLoading
);

export const loadingMessageSelector = createSelector(
    selectLoadingState,
    loading => loading.loaderMessage
);

component.ts

  isLoading$: Observable<boolean>;
  isLoading: boolean;
  loadingMessage$: Observable<string>;
  loadingMessage: string;

  ngOnInit(): void {
    this.isLoading$ = this.store.pipe(select(isLoadingSelector));
    this.loadingMessage$ = this.store.pipe(select(loadingMessageSelector));

    this.isLoading$.subscribe(l => {
      console.log("Loading value: ", l);
      setTimeout(() => {
        this.isLoading = l;
      }, 0);
    });

    this.loadingMessage$.subscribe(lm => {
      console.log("Loading message: ", lm);
      setTimeout(() => {
        this.loadingMessage = lm;
      }, 0);
    });
  }

component.html

<div [hidden]="!(isLoading$ | async)" class="overlay displayO">
  <div class="overlay-info">
    <mat-spinner class="spinner"></mat-spinner>
    <!-- throws error {{loadingMessage$ | async}} -->
    {{loadingMessage}}
  </div>
</div>

dispatching example

  loadQuestionsPage$ = createEffect(() =>
    this.actions$.pipe(
      ofType(QuestionsPageRequestedAction),

      mergeMap(payload => {
        this.logger.logTrace("loadQuestionsPage$ effect triggered for type QuestionsPageRequestedAction");

        this.store.dispatch(LoadingStartedAction({ message: "Loading question list ..."}));

        return this.questionService.loadQuestionsPage(payload.page.pageIndex, payload.page.pageSize).pipe(
          tap(_ => this.store.dispatch(LoadingEndedAction())),
          catchError(err => {
            this.store.dispatch(LoadingEndedAction());
            this.logger.logErrorMessage("Error loading questions: " + err);

            this.store.dispatch(QuestionsPageCancelledAction());
            return of(<QuestionListModel[]>[]);
          })
        );
      }),
      map(questions => {
        const ret = QuestionsPageLoadedAction({ questions });
        this.logger.logTrace("loadQuestionsPage$ effect QuestionsPageLoadedAction: ", ret);
        return ret;
      })
    )
  );

Redux dev tools shows a single LoadingStartedAction and a single LoadingEndedAction.

If I use the async, I get the error:

Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'undefined'. Current value: 'false'. It seems like the view has been created after its parent and its children have been dirty checked. Has it been created in a change detection hook ?
    at throwErrorIfNoChangesMode (core.js:6453) [angular]
    at bindingUpdated (core.js:16425) [angular]
    at bind (core.js:16524) [angular]
    at ɵɵproperty (core.js:16506) [angular]
    at AppComponent_Template (template.html:46) [angular]
    at executeTemplate (core.js:9596) [angular]
    at checkView (core.js:11020) [angular]
    at componentRefresh (core.js:10778) [angular]
    at refreshChildComponents (core.js:9277) [angular]
    at refreshDescendantViews (core.js:9176) [angular]
    at renderComponentOrTemplate (core.js:9567) [angular]
    at tickRootContext (core.js:10926) [angular]
    at detectChangesInRootView (core.js:10962) [angular]
    at checkNoChangesInRootView (core.js:10992) [angular]

However, if use the value got through the explicit subscription (i.e. <div [hidden]="!isLoading" class="overlay displayO">) it works without the error.

I saw that it says something about undefined and false as if isLoading is set to false during initialization, but the initial state defines directly as false.

I have encountered this error if the bound property is changing multiple times in a function, but I cannot see how I am doing that here.

Not sure if it matters, but I am also using storeFreeze meta reducer to prevent state being mutated:

export const metaReducers: MetaReducer<AppState>[] =
  !environment.production ? [localStorageSyncReducer, storeFreeze] : [];

Question: How to deal with ExpressionChangedAfterItHasBeenCheckedError when subscribing to Observable using async pipe?

0

There are 0 best solutions below