0

I've come across a situation a few times where I use a shared service in a component's ngOnInit to update a value in another component, which just so happens to be a parent.

This results in the infamous Error: NG0100: Expression has changed after it was checked in development mode, and change detection will not pick up the change. I understand why this happens and it is expected behaviour.

My strategy is to use a zero delay setTimeout() to defer the code execution until after the initial change detection has finished. For reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop#zero_delays

To me, it seems like a workaround for something very common, and I feel Angular probably has another solution I'm not aware of. So my question is: Is there an Angular way to update a parent component with a shared service in ngOnInit.

I have tried all other lifecycle hooks and they all result in the same error as well. Using a subject and an async pipe is the same.

Here's an example: a service that changes the font color of the main component when a child component is opened: https://stackblitz.com/edit/angular-ivy-kkvarp?file=src/app/global-style.service.ts

Service

export class GlobalStyleService {
  private _green = false;

  set green(value: boolean) {
    // Uncomment to get an error
    // this._green = value;

    //For use in lifecycle hooks
    //Delay setting until after change detection completes
    setTimeout(() => (this._green = value));
  }

  get green() {
    return this._green;
  }
}

app.component.ts

export class AppComponent {
  constructor(public globalStyle: GlobalStyleService) {}
}

app.component.html

<app-main [class.green]="globalStyle.green"></app-main>

styles.css

.green {
  color: green;
}

main.component.html

<div class="container">
  <h1>Main Component</h1>
  <button (click)="testComponentOpen = !testComponentOpen">
    Toggle Test Component
  </button>
  <app-test *ngIf="testComponentOpen"></app-test>
</div>

test.component.ts

export class TestComponent implements OnInit, OnDestroy {
  constructor(private globalStyle: GlobalStyleService) {}

  ngOnInit() {
    this.globalStyle.green = true;
  }

  ngOnDestroy() {
    this.globalStyle.green = false;
  }
}

Opening the test component sets the service variable, which changes the style of the main component. If you set the variable without using setTimeout() you will get the following error:

Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value for 'green': 'false'. Current value: 'true'

And the text will not turn green until you trigger another round of change detection.

Using setTimeout() works, but is there an Angular way? Something that explicitly defers the code execution if change detection is already in progress?

Chris Hamilton
  • 9,252
  • 1
  • 9
  • 26
  • Hum, Why not use Subject or BehaviourSubject for this component communication, more safe without the need to use setTimeout – Rebai Ahmed Mar 01 '22 at 09:58
  • @RebaiAhmed As I said in my question, subject and async pipe result in the same error unfortunately. You are still changing a parent value in ngOnInit, before change detection completes. `setTimeout()` is still necessary in that case. I just used a boolean to keep the example as simple as possible. – Chris Hamilton Mar 01 '22 at 10:02
  • Did you checked these answers : https://stackoverflow.com/questions/34364880/expression-has-changed-after-it-was-checked – Rebai Ahmed Mar 01 '22 at 10:06
  • @RebaiAhmed Unfortunately, that question does not involve changing a variable within a shared service. ChangeDetectorRef and ChangeDetectionStrategy are not applicable to a service. The only other solution on that thread is the `setTimeout()` function. – Chris Hamilton Mar 01 '22 at 10:19
  • I'm still not in favor for setTimeout() with reactive programming! at least you can check timer operator with on observable instead – Rebai Ahmed Mar 01 '22 at 10:40
  • @RebaiAhmed yeah I'm not in favor of `setTimeout()` either, that's the whole point of the question. If you have a solution please fork the stackblitz and show it. – Chris Hamilton Mar 01 '22 at 10:43
  • I changed to timer(0).subscribe(time => this._green = value); , you can check it and it's working fine – Rebai Ahmed Mar 01 '22 at 12:40
  • @RebaiAhmed sorry but timer is just a wrapper for setTimeout(), so I don't see how that's any different? – Chris Hamilton Mar 01 '22 at 18:26
  • I mentioned timer because it's an rxjs operator and you can handle subscription inside component or service and it's recommended better than using native js API with angular – Rebai Ahmed Mar 01 '22 at 21:13

1 Answers1

1

After doing an excessive amount of my own research around the same problem (specifically, a loading spinner service referring to an overlay div in the top level component, which I imagine is an extremely common use case) I'm pretty confident that unfortunately, the answer to your specific question (is there an Angular way?) is: no, not really.

Angular's state stability checks are hierarchical and unidirectional by design (meaning parent component state is considered immutable when child components resolve), which requires that components know where they are in the hierarchy in order to behave properly, which is pretty incompatible with creating generic, re-usable components.

Especially for communicating changes via services (as you and I are doing), there's no reliable solution other than waiting until after state checks, via something asynchronous.

So the design question is where to hide that mess. You've put it in the service, which is good for shielding the components from dealing with it, but it does mean that all sets are asynchronous, which is unintuitive code and exposes you to race conditions (e.g. you could have some component use this service, set and get the value, and it would be very unclear why the get didn't match the set you just called).

The best I've come up with is to create an AsyncComponent base class that takes over ngOnInit (and other lifecycle callbacks) and asynchronously calls the derived class ngOnXXXAsync. It still means that components need to know they need this, but it shields them from dealing with the async logic and means all of the code in the init method is still internally executed synchronously.

Example based on yours:

async.component.ts

import { Component, OnInit, OnDestroy } from '@angular/core';

@Component({
  template: '',
})
export abstract class AsyncComponent implements OnInit, OnDestroy {
  ngOnInit(): void {
    Promise.resolve(null).then(() => this.ngOnInitAsync());
  }

  ngOnDestroy(): void {
    Promise.resolve(null).then(() => this.ngOnDestroyAsync());
  }

  // Override for async initialization.
  ngOnInitAsync(): void {}

  // Override for async destruction.
  ngOnDestroyAsync(): void {}
}

test.component.ts

export class TestComponent extends AsyncComponent {
  constructor(private globalStyle: GlobalStyleService) {
    super();
  }

  ngOnInitAsync() {
    this.globalStyle.green = true;
  }

  ngOnDestroyAsync() {
    this.globalStyle.green = false;
  }
}

I've forked your stackblitz with this solution here: https://stackblitz.com/edit/angular-ivy-9djhyh?file=src%2Fapp%2Fasync.component.ts,src%2Fapp%2Ftest%2Ftest.component.ts

It also includes a demonstration of the problem with making the set in the service async.. if you turn that back on it'll throw an error when .get doesn't match what was .set immediately prior.

sidereal
  • 1,072
  • 7
  • 15
  • That's a good solution, but the fact that this is necessary is pretty janky. I swear the more I learn about Angular the less I like it lol. You could probably switch `Promise.resolve(null).then(() => this.ngOnDestroyAsync());` to just `setTimeout(this.ngOnDestroyAsync());` aswell. That's the simplest way to push something on to the queue. – Chris Hamilton Jun 07 '22 at 05:02
  • One nitpick about your answer, you may or may not be aware of this already. Javascript is single-threaded, so everything runs synchronously and there are no race conditions (unless you're waiting for an external response like an API). There is a stack and a queue, "async" tasks get pushed on to the queue, and they only get executed when the stack is empty. All "sync" code is on the stack. So you're not running things asynchronously, you're just changing the order of operations. Web Workers do exist for multi-threading though. – Chris Hamilton Jun 07 '22 at 05:17
  • Yeah, good catch. 'Race condition' in this example is a bit loose language, when really it's 'extremely unexpected execution order for your service's clients'. In my (loading spinner) use case it really was a race condition since network was involved. – sidereal Jun 08 '22 at 15:06
  • On Promise vs setTimeout, there's a subtle difference. Promise.resolve(null) (usually? hopefully?) creates a microtask and setTimeout creates a macrotask. On its own that probably doesn't matter, but mixed in with Angular's tasks it might be better to minimize the latency to the delayed task's execution – sidereal Jun 08 '22 at 15:12
  • Neat, I didn't know about microtasks / macrotasks. Apparently you can just do `queueMicrotask(this.ngOnDestroyAsync)` for that. – Chris Hamilton Jun 09 '22 at 03:36
  • @sidereal In the case of the child loading data and setting the loading spinner in the parent view, you can simply use a [resolver](https://angular.io/api/router/Resolve). Angular allows the parent to be updated there because the child isn't created yet. I think that's a more elegant solution than the changing the spinner state in the next event loop. – blue shell Aug 05 '22 at 12:17