Ideal method of re-entering ngZone following an EventEmitter event

There is a specific component that wraps around a library, and to prevent the chaos of change detection caused by event listeners in this library, the library is kept outside the Angular zone:

@Component({ ... })
export class TestComponent {

  @Output()
  emitter = new EventEmitter<void>();

  constructor(private ngZone: NgZone) {}

  ngOnInit() {
    this.ngZone.runOutsideAngular(() => {
        // ...
    });    
  }

}

This setup is pretty straightforward and commonly used. Now, let's introduce an event to trigger the action:

@Component({ ... })
export class TestComponent {

  @Output()
  emitter = new EventEmitter<void>();

  private lib: Lib;

  constructor(private ngZone: NgZone) {}

  ngOnInit() {
    this.ngZone.runOutsideAngular(() => {
      this.lib = new Lib();
    });

    this.lib.on('click', () => {
      this.emitter.emit();
    });
  }

}

The issue here is that the emitter does not trigger change detection since it operates outside the zone. One way to address this is by reentering the zone:

@Component({ ... })
export class TestComponent {

  @Output()
  emitter = new EventEmitter<void>();

  private lib: Lib;

  constructor(private ngZone: NgZone) {}

  ngOnInit() {
    this.ngZone.runOutsideAngular(() => {
      this.lib = new Lib();
    });

    this.lib.on('click', () => {
      this.ngZone.run(() => this.emitter.emit());
    });
  }

}

Now comes the question. The this.ngZone.run call enforces change detection even if the event was not subscribed to in the parent component:

<test-component></test-component>

This behavior is undesirable because there are no subscribers for that event => no need for detection.

How can we solve this dilemma?

For those interested in a practical example, the root of this question can be found here.

Answer №1

One important thing to remember is that using an @Output() binding to emit a value serves as a trigger for change detection in the parent component. Even if there are no direct listeners for this binding, there may still be references to the component in the parent template, such as through exportAs or a @ViewChild query. Therefore, emitting a value notifies the parent that the component's state has been updated, even if indirectly. While it is possible that future updates from the Angular team could affect this behavior, this is how it currently functions.

If you wish to avoid triggering change detection for a specific observable, consider not using the @Output decorator at all. Instead, remove the decorator and access the emitter property directly using exportAs or by utilizing a @ViewChild in the parent component.

Consider looking at how reactive forms operate. Control directives often have public observables for changes that do not rely on @Output. These observables can be subscribed to without triggering change detection unnecessarily.

To create an observable that is decoupled from change detection, simply make it a public observable. This approach keeps things straightforward. Attempting to add logic to only emit when there is a subscriber to an @Output can complicate the component and make the code harder to understand later on.

In conclusion, my recommendation would be to use @Output() only when there is a genuine need for a subscriber.

@Component({})
export class TestComponent implements OnInit {

    private lib: Lib;

    constructor(private ngZone: NgZone) {
    }

    @Output()
    public get emitter(): Observable<void> {
        return new Observable((subscriber) => {
            this.initLib();
            this.lib.on('click', () => {
                this.ngZone.run(() => {
                    subscriber.next();
                });
            });
        });
    }

    ngOnInit() {
        this.initLib();
    }

    private initLib() {
        if (!this.lib) {
            this.ngZone.runOutsideAngular(() => {
                this.lib = new Lib();
            });
        }
    }
}

If I were to revisit this code in the future, I might find it confusing why the developer chose to implement it this way. The additional complexity added does not clearly address the problem being solved.

Answer №2

Big thank you to the answer provided by cgTag. It pointed me in a more clear and user-friendly direction, emphasizing the use of Observables for their natural laziness instead of relying on getters.

Below is a detailed example:

export class Component {

  private lib: any;

  @Output() event1 = this.createLazyEvent('event1');

  @Output() event2 = this.createLazyEvent<{ eventData: string; }>('event2');

  constructor(private el: ElementRef, private ngZone: NgZone) { }

  // creates an event emitter that binds to the library event
  // only when somebody explicitly calls for it: `<my-component (event1)="..."></my-component>`
  private createLazyEvent<T>(eventName: string): EventEmitter<T> {
    // return an Observable that is treated like EventEmitter
    // because EventEmitter extends Subject, Subject extends Observable
    return new Observable(observer => {
      // this is mostly required because Angular subscribes to the emitter earlier than most of the lifecycle hooks
      // so the chance library is not created yet is quite high
      this.ensureLibraryIsCreated();

      // here we bind to the event. Observables are lazy by their nature, and we fully use it here
      // in fact, the event is getting bound only when Observable will be subscribed by Angular
      // and it will be subscribed only when gets called by the ()-binding
      this.lib.on(eventName, (data: T) => this.ngZone.run(() => observer.next(data)));

      // important what we return here
      // it is quite useful to unsubscribe from particular events right here
      // so, when Angular will destroy the component, it will also unsubscribe from this Observable
      // and this line will get called
      return () => this.lib.off(eventName);
    }) as EventEmitter<T>;
  }

  private ensureLibraryIsCreated() {
    if (!this.lib) {
      this.ngZone.runOutsideAngular(() => this.lib = new MyLib());
    }
  }

}

Here is another instance where the observable from the library instance is utilized (emitting the library instance each time it is recreated, which is quite common):

  private createLazyEvent<T>(eventName: string): EventEmitter<T> {
    return this.chartInit.pipe(
      switchMap((chart: ECharts) => new Observable(observer => {
        chart.on(eventName, (data: T) => this.ngZone.run(() => observer.next(data)));
        return null; // no need to react on unsubscribe as long as the `dispose()` is called in ngOnDestroy
      }))
    ) as EventEmitter<T>;
  }

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

Compilation of various Typescript files into a single, encapsulated JavaScript bundle

After researching countless similar inquiries on this topic, I have come to the realization that most of the answers available are outdated or rely on discontinued NPM packages. Additionally, many solutions are based on packages with unresolved bug reports ...

An issue occurred while attempting to retrieve Firebase data using an HTTP GET request

When trying to retrieve my data from firestore using an HTTP get request, I encountered an error. It might be helpful if my data in firestore was stored in JSON format. I'm not sure if this is feasible. <!DOCTYPE html> <html lang="en"> ...

The performance of Ionic 2 app views starts to lag

I'm currently working on an app with Ionic 2 that involves a timer functionality. Initially, the timer operates smoothly with instant updates. However, when viewed in Ionic View, the update frequency significantly decreases over time. Even when tested ...

Issues with Angular routing after upgrading to Angular 4

After making updates in this way, they can be found here To update on Linux/Mac: run the command npm install @angular/{common,compiler,compiler-cli,core,forms,http,platform-browser,platform-browser-dynamic,platform-server,router,animations}@latest type ...

What is the best way to generate a random item when a button is clicked?

I'm currently working on a feature in my component that generates a random item each time I access the designated page. While the functionality is set to automatically refresh and showcase a new random item, I am now looking to trigger this action man ...

What is the best approach for initializing and adding dataset in a database using Nest.JS when launching the application for the first time?

In managing my database, I have multiple tables that require default information such as categories, permissions, roles, and tags. It is crucial for me to ensure that this exact information has consistent IDs whenever the application is freshly launched on ...

Retrieve the parent document for every item within a Firebase collection group

Transitioning from an SQL background to document storage, I am currently navigating through a Firebase database structure that looks like this: John (doc) Restaurant Reviews (collection) Review 1 (doc) Review 2 (doc) Paul (doc) Restaurant Reviews ...

Excluding node modules when not included in tsconfig

Within my Angular project, there is a single tsconfig file that stands alone without extending any other tsconfigs or including any additional properties. Towards the end of the file, we have the following snippet: "angularCompilerOptions": { ...

Adjusting the value of a mat-option depending on a condition in *ngIf

When working with my mat-option, I have two different sets of values to choose from: tempTime: TempOptions[] = [ { value: 100, viewValue: '100 points' }, { value: 200, viewValue: '200 points' } ]; tempTimesHighNumber: TempOpt ...

I am unable to utilize the outcome of a custom hook within a function or within an effect hook

I've developed a unique custom hook that retrieves a list of individuals File: persons.hooks.ts import {useEffect, useState} from "react"; import Person from "../../models/person/Person"; const usePersons = () => { const ...

Encountering an error of ExpressionChangedAfterItHasBeenCheckedError while trying to refresh the

I'm encountering an issue that I need help with: https://i.stack.imgur.com/4M54x.png whenever I attempt to update the view using *ngIf to toggle on an icon display. This is what my .ts file looks like: @Component({ selector: 'app-orders&ap ...

Guide to integrating Inversify with Mocha

In my TypeScript Node.js application, I am implementing Dependency Injection using Inversify. The functionality works perfectly during the app's execution. However, I encountered an issue with the @injectable() annotation when running tests. An error ...

The RazorPay callback encountered an Uncaught TypeError indicating that the function is not recognized

In my TypeScript class, I have a defined handler as follows: "handler": function (response) { this.sendUserStatus(); }, Unfortunately, when I attempt to call this.sendUserStatus();, I encounter the following error: Uncaught Typ ...

In Angular Mat Stepper, only allow navigation to active and completed steps

For a project, I created a sample using React.js and Material UI. Here is the link to the project: https://stackblitz.com/edit/dynamic-stepper-react-l2m3mi?file=DynamicStepper.js Now, I am attempting to recreate the same sample using Angular and Material, ...

What are the appropriate scenarios for extending and utilizing an abstract class in Angular?

@Component({ selector: 'my-component', template: `<ng-content></ng-content>`, providers: [ { provide: AbstractClass, useExisting: forwardRef(() => TargetComponent) } ] }) export class TargetComponent extends AbstractCla ...

Encountering an obscure issue when using Discord.js v14 after attempting to cancel and resubmit a modal

I'm currently working on a Discord bot using modals in Discord.js v14. These modals appear after the user clicks a button, and an .awaitModalSubmit() collector is triggered to handle one modal submission interaction by applying certain logic. The .awa ...

Encountering a timeout issue with the Sinch API within an Angular 2 project during the onCallProgressing

We successfully integrated Sinch into our angular 2 web application. Most functionalities are working perfectly, except for the user calling feature using the sinch phone demo. When the application is in the foreground, the call rings and connects withou ...

Previewing files from external URLs with Ionic 5 Capacitor and Angular

I recently implemented the previewanyfile cordova plugin in my Ionic 5 application to open files from external URLs. While it works smoothly on Android devices, I have encountered an issue on iOS where some PDF files fail to preview and instead display a g ...

Tips for working with Typescript: utilizing the default value for a non-existent computed property

When utilizing Computed Property in javascript, I can structure my code as follows const default_values = {a:"debug",b:"info",c:"warning"}; function execute(y) { let x = default_values[y] || default_values.a /* if y is no ...

Creating a custom data type using values from a plain object: A step-by-step guide

I recently came across an object that looks like this: const myObject = { 0: 'FIRST', 10: 'SECOND', 20: 'THIRD', } My goal is to define a type using the values from this object, similar to this: type AwesomeType = &apos ...