angularangular-http-interceptorsangular-changedetectionangular-signals

"expression has changed after it was checked" error despite having the right setup


I'm facing the error expression has changed after it was checked. I know, that this check runs only in dev mode (runs twice) and that I won't have the error message in prod. But I want to know why I get this.

I have a signal-based loader service like this:

import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class LoaderService {
  private _isLoading = signal<boolean>(false);

  get isLoading(): boolean {
    return this._isLoading();
  }

  show() {
    this._isLoading.update(() => true);
  }

  hide() {
    this._isLoading.update(() => false);
  }
}

I have also this interceptor:

import {
  HttpEvent,
  HttpHandler,
  HttpInterceptor,
  HttpRequest,
  HttpResponse,
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, catchError, map, throwError } from 'rxjs';
import { USE_LOADING_SPINNER } from '../constants/app.constants';
import { LoaderService } from '../../shared/services/loader.service';

@Injectable()
export class LoadingInterceptor implements HttpInterceptor {
  constructor(private loaderService: LoaderService) {}

  intercept(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    if (req.context.get(USE_LOADING_SPINNER)) this.loaderService.show();

    return next.handle(req).pipe(
      map((event: HttpEvent<any>) => {
        if (event instanceof HttpResponse) this.loaderService.hide();
        return event;
      }),
      catchError((error) => {
        this.loaderService.hide();
        return throwError(() => error);
      })
    );
  }
}

The USE_LOADING_SPINNER is always set to true, which means I have to mark only the requests where I don't want to have any loading indicator.

Since the LoaderService is signal-based, I register an effect in the constructor of a component like this:

constructor() {
    effect(() => {
      this.loading = this.loaderService.isLoading;
    });
  }

Note the loading variable isnt't a signal, it is a pure boolean variable, since the isLoading method returns a boolean value, not a signal.

And in the template I have this in a grid component:

[loading]="loading"

This setup is working, on initial routing the loading indicator is showing.

Now I add a refresh method to the component like this:

refresh() {
    //this.loaderService.show();
    this.gridData$ = this.userService.getUsers();
    //.pipe(tap(() => this.loaderService.hide()));
  }

But if I click on refresh, I get the error in the topic title and the loading indicator isn't showing. The interceptor gets triggered, I checked this.

But if I uncomment the calls to the loaderService it works as expected.

Why do I get this error?


Solution

  • I think the error expression has changed after it was checked might be due to the update of loading after the view has been initialized. Instead go for a signal based update, which might not trigger this error at all.


    I think it is due to improper use of getter and signal, instead of executing the signal at the service level, return the actual signal.

    When you want to do something with the previous state, then use update, else set is a better option.

    Your signal is private and you just want to return a readonly version of the signal, so we use asReadonly() to remove the writable operations set and update when the component uses this signal.

    import { Injectable, signal } from '@angular/core';
    
    @Injectable({ providedIn: 'root' })
    export class LoaderService {
      private _isLoading = signal<boolean>(false);
    
      get isLoading(): boolean {
        return this._isLoading.asReadonly(); // prevent modification of the signal
      }
    
      show() {
        this._isLoading.set(true);
      }
    
      hide() {
        this._isLoading.set(false);
      }
    }
    

    Now, on the component level, instead of effect which is overkill, just assign the service property to a component property.

    // or
    // loading = this.loaderService.isLoading;
    
    constructor() {
      this.loading = this.loaderService.isLoading;
    }
    

    At the template level, execute the signal.

    [loading]="loading()"