Skip to content
Advertisement

What is the proper way to update a parent component with a shared service in ngOnInit – Angular 13

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?

Advertisement

Answer

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.

User contributions licensed under: CC BY-SA
2 People found this is helpful
Advertisement