Skip to content

Proposal: Provide Component Lifecycle Hooks as Observables #10185

Closed as not planned

Description

@Jeff-Cortese

I'm submitting a ...

[ ] bug report
[x] feature request
[ ] support request

Current behavior
Currently, component lifecycle hooks are handled by implementing one or more interfaces provided by angular. I find myself often wanting to subscribe to an observable until the component/directive is destroyed. To do this, I have to have a member for each subscription for later disposal in the ngOnDestroy() method.
Example:

/* imports */

@Directive({
  selector: '[onDrag]'
})
export class OnDrag implements OnInit, OnDestroy {
  dragStart: Subscription;
  dragEnter: Subscription;
  dragOver: Subscription;
  dragLeave: Subscription;

  constructor(private element: ElementRef) { }

  ngOnInit(): any {
    let elem = this.element.nativeElement;

    this.dragStart = Observable.fromEvent(elem, 'dragstart')
      .do(() =>{ /*do stuff*/ })
      .subscribe();

    this.dragEnter = Observable.fromEvent(elem, 'dragenter')
      .do(() =>{ /*do stuff*/ })
      .subscribe();

    this.dragOver = Observable.fromEvent(elem, 'dragover')
      .do(() =>{ /*do stuff*/ })
      .subscribe();

    this.dragLeave = Observable.fromEvent(elem, 'dragleave')
      .do(() =>{ /*do stuff*/ })
      .subscribe();

    // etc...
  }

  ngOnDestroy(): any {
    this.dragStart.unsubscribe();
    this.dragEnter.unsubscribe();
    this.dragOver.unsubscribe();
    this.dragLeave.unsubscribe();
  }
}

Desired behavior
My suggestion is to provide components with an observable for each lifecycle event (via injection perhaps?). The above example could turn into something like:

/* imports */

@Directive({
  selector: '[onDrag]'
})
export class OnDrag implements OnInit {
  constructor(
    private element: ElementRef,
    private lifecycle: ThingWithLifecycleObservablesForThisComponent
  ) { }

  ngOnInit(): any {
    let elem = this.element.nativeElement;

    Observable.fromEvent(elem, 'dragstart')
      .do(() =>{ /*do stuff*/ })
      .takeUntil(this.lifecycle.ngDestroy$)
      .subscribe();

    Observable.fromEvent(elem, 'dragenter')
      .do(() =>{ /*do stuff*/ })
      .takeUntil(this.lifecycle.ngDestroy$)
      .subscribe();

    Observable.fromEvent(elem, 'dragover')
      .do(() =>{ /*do stuff*/ })
      .takeUntil(this.lifecycle.ngDestroy$)
      .subscribe();

    Observable.fromEvent(elem, 'dragleave')
      .do(() =>{ /*do stuff*/ })
      .takeUntil(this.lifecycle.ngDestroy$)
      .subscribe();

    // etc...
  }
}

What is the motivation / use case for changing the behavior?
Ultimately, I would like a way to not have to hold onto subscriptions as class members (perhaps there is a better way to achieve this than my suggestion). It could be convenient/concise to be able to handle lifecycle events as observables within components. As a workaround currently I have been extending my components with a base class that provides (some) of these events:

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

export class ComponentLifeCycle implements OnInit, OnDestroy {
  protected ngInit$: Subject<any> = new Subject<any>();
  protected ngDestroy$: Subject<any> = new Subject<any>();

  ngOnInit(): any {
    this.ngInit$.next({});
  }

  ngOnDestroy(): any {
    this.ngDestroy$.next({});
  }
}

Please tell us about your environment:

  • Angular version: 2.0.0-rc.4
  • Browser: all
  • Language: all

Activity

  1. added
    area: coreIssues related to the framework runtime
    featureLabel used to distinguish feature request from other issues
    on Oct 4, 2016
  2. bobvanderlinden commented on Aug 28, 2017

    @bobvanderlinden

    To get this further along, would it be possible to hook lifecycle events outside of a component? That would make it possible to create a third party library that provides observable events.

  3. sod commented on Sep 25, 2017

    @sod
    Contributor

    So there mere presence of a method ngOnDestroy lets angular call it upon component destruction.

    How about the mere presence of a:

    class MyComponent {
        public ngDestroy = new EventEmitter<void>();
    }

    and angular does componentInstance.ngDestroy.next(); upon destruction?

  4. bobvanderlinden commented on Sep 25, 2017

    @bobvanderlinden

    Just to share my findings: I have tried to set ngOnDestroy inside the constructor, but this will not work. It seems to check whether the method is created using reflection metadata.

  5. Jeff-Cortese commented on Sep 25, 2017

    @Jeff-Cortese
    Author

    @ohjames I hadn't considered AoT when I first wrote this issue. A year later, we are using AoT'ed builds in production at my work and have never hit the issue you're describing. I tested out that scenario by logging out emitted ngDestroy$ values without calling super.ngOnDestroy(), and it appears to be working, meaning the base class's ngOnDestroy() was not compiled out.

  6. bygrace1986 commented on Oct 6, 2017

    @bygrace1986

    @sod this may be similar to what you wanted.

    I put together an alternate approach using property decorators if anyone prefers that flavor over inheritance. I haven't tested it with AOT yet. If the angular team wrote it then they could get tighter integration without needing to hijack the life-cycle callback methods on the component.

    https://stackoverflow.com/questions/40737752/anuglar2-lifecycle-events-as-rxjs-observable/46572739#46572739

    life-cycle.decorator.ts

    import { SimpleChanges } from '@angular/core';
    import { Subject } from 'rxjs/Subject';
    
    /**
     * Creates an observable property on an object that will
     * emit when the corresponding life-cycle event occurs.
     * The main rules are:
     * 1. Don't name the property the same as the angular interface method (i.e. ngOnInit).
     * 2. If a class inherits from another component where the parent uses this decorator
     *    and the child implements the corresponding interface then it needs to call the parent method.
     * @param {string} lifeCycleMethodName name of the function that angular calls for the life-cycle event
     * @param {object} target class that contains the decorated property
     * @param {string} propertyKey name of the decorated property
     */
    function applyLifeCycleObservable(
        lifeCycleMethodName: string,
        target: object,
        propertyKey: string
    ): void {
        // Save a reference to the original life-cycle callback so that we can call it if it exists.
        const originalLifeCycleMethod = target.constructor.prototype[lifeCycleMethodName];
    
        // Use a symbol to make the observable for the instance unobtrusive.
        const instanceSubjectKey = Symbol(propertyKey);
        Object.defineProperty(target, propertyKey, {
            get: function() {
                // Get the observable for this instance or create it.
                return (this[instanceSubjectKey] || (this[instanceSubjectKey] = new Subject<any>())).asObservable();
            }
        });
    
        // Add or override the life-cycle callback.
        target.constructor.prototype[lifeCycleMethodName] = function() {
            // If it hasn't been created then there no subscribers so there is no need to emit
            if (this[instanceSubjectKey]) {
                // Emit the life-cycle event.
                // We pass the first parameter because onChanges has a SimpleChanges parameter.
                this[instanceSubjectKey].next.call(this[instanceSubjectKey], arguments[0]);
            }
    
            // If the object already had a life-cycle callback then invoke it.
            if (originalLifeCycleMethod && typeof originalLifeCycleMethod === 'function') {
                originalLifeCycleMethod.apply(this, arguments);
            }
        };
    }
    
    // Property Decorators
    export function OnChangesObservable(target: any, propertyKey: string) {
        applyLifeCycleObservable('ngOnChanges', target, propertyKey);
    }
    export function OnInitObservable(target: any, propertyKey: string) {
        applyLifeCycleObservable('ngOnInit', target, propertyKey);
    }
    export function DoCheckObservable(target: any, propertyKey: string) {
        applyLifeCycleObservable('ngDoCheck', target, propertyKey);
    }
    export function AfterContentInitObservable(target: any, propertyKey: string) {
        applyLifeCycleObservable('ngAfterContentInit', target, propertyKey);
    }
    export function AfterContentCheckedObservable(target: any, propertyKey: string) {
        applyLifeCycleObservable('ngAfterContentChecked', target, propertyKey);
    }
    export function AfterViewInitObservable(target: any, propertyKey: string) {
        applyLifeCycleObservable('ngAfterViewInit', target, propertyKey);
    }
    export function AfterViewCheckedObservable(target: any, propertyKey: string) {
        applyLifeCycleObservable('ngAfterViewChecked', target, propertyKey);
    }
    export function OnDestroyObservable(target: any, propertyKey: string) {
        applyLifeCycleObservable('ngOnDestroy', target, propertyKey);
    }
    

    Example Usage
    I implement OnInit here to demonstrate that they work together but you could subscribe to onInit instead

    import { Component, OnInit, Input, SimpleChange } from '@angular/core';
    import { Observable } from 'rxjs/Observable';
    
    import {
        OnChangesObservable,
        OnInitObservable,
        DoCheckObservable,
        AfterContentInitObservable,
        AfterContentCheckedObservable,
        AfterViewInitObservable,
        AfterViewCheckedObservable,
        OnDestroyObservable
     } from './life-cycle.decorator';
    import { MyService } from './my.service'
    
    @Component({
        template: ''
    })
    export class TestDecoratorComponent implements OnInit {
    
        @OnChangesObservable
        onChanges: Observable<SimpleChanges>;
        @OnInitObservable
        onInit: Observable<void>;
        @DoCheckObservable
        doCheck: Observable<void>;
        @AfterContentInitObservable
        afterContentInit: Observable<void>;
        @AfterContentCheckedObservable
        afterContentChecked: Observable<void>;
        @AfterViewInitObservable
        afterViewInit: Observable<void>;
        @AfterViewCheckedObservable
        afterViewChecked: Observable<void>;
        @OnDestroyObservable
        onDestroy: Observable<void>;
    
        @Input()
        input: string;
    
        constructor(private myService: MyService) {
        }
    
        ngOnInit() {
            this.myService.takeUntil(this.onDestroy).subscribe(() => {});
            this.onChanges
                .map(x => x.input)
                .filter(x => x != null)
                .takeUntil(this.onDestroy)
                .subscribe((change: SimpleChange) => {
                });
        }
    }
    
  7. insidewhy commented on Oct 9, 2017

    @insidewhy

    @bygrace1986 Yeah that definitely won't work with AoT. The AoT compiler needs to be able to statically infer the existence of lifecycle methods in the component, it can't infer these from code run as part of a decorator.

    In general with Angular 2/4, a million things are trivial to do with decorators, but barely any of these will work once you factor in AoT.

  8. bygrace1986 commented on Oct 9, 2017

    @bygrace1986

    @ohjames your right. I'd rather use the decorators to insert the hooks but I couldn't find any hacky way to make it work so I reverted to using a base class.

  9. sandangel commented on Oct 9, 2017

    @sandangel

    any chance this proposal will be implemented?

  10. insidewhy commented on Oct 11, 2017

    @insidewhy

    Without this proposal there is no way to include lifecycle events as part of rxjs pipelines or to hook into lifecycle events without abusing inheritance.

    At my consultancy we're using Angular 2/4 with rxjs heavily. This issue together with the lack of observables to monitor @Input makes us question if VueJS or cycle.js would be a better fit for us given our love of rxjs.

    There's this uncomfortable middle ground with Angular 4 where most things are trivial to do with observables and yet other things that should be trivial are obstructed by brick walls.

  11. mlc-mlapis commented on Oct 11, 2017

    @mlc-mlapis
    Contributor

    @ohjames ... for monitoring @Input() you can use setter because it is invoked also when there is a change.

  12. insidewhy commented on Oct 11, 2017

    @insidewhy

    @mlc-mlapis Yep, that's precisely the approach I'm using in my angular utility library https://github.com/ohjames/observable-input. Having to define a setter + Observable member for every @Input() is a bit too much boilerplate for me.

  13. 30 remaining items

  14. fabioemoutinho commented on Aug 29, 2022

    @fabioemoutinho
    Contributor

    Personally I would love to see something like:

    @ReactiveComponent({ ... }}
    class MyReactiveComponent {
      @ReactiveInput() myInput$: Observable<number>;
      protected ngOnDestroy$: Observable<void>;
    }

    I don't know if this is achievable, but in this way you could have this be implemented only in @ReactiveComponent and it would not mess with current @Component API.
    ngOnDestroy$ and other lifecycle observables would be implemented by the @ReactiveComponent. And maybe with the @ReactiveInput, we could finally have fully reactive components 🙌

  15. tayambamwanza commented on Oct 10, 2022

    @tayambamwanza

    I really hope this gets implemented along with observable inputs, ever since I realised this could be done I realised how much it would make the reactive authoring experience in Angular so much more DX friendly. Using the Decorator lifecycle hooks and even the inputs actually forces you out of getting the most from the experience, which is funny because I was introduced to reactivity by Angular and after understanding it better I realised Angulars implementation is missing a few key features to take it from ok to amazing.

    I hope along with any other primitives they do lean into the strength of RXJS that many have come to love, I think the fact that many frameworks are developing reactive primitives which mimic rxjs in a more lightweight manner shows once again that Angular was actually ahead of it's time like with typescript.

  16. niklaas commented on Oct 10, 2022

    @niklaas

    I really hope this gets implemented along with observable inputs

    What do you mean by "observable inputs"? You can already pass in observables to a component using an input binding. Do you also want the subscription to be managed by the component automatically?

  17. tayambamwanza commented on Oct 10, 2022

    @tayambamwanza

    @niklaas Basically what I want is to be able to subscribe to the input rather than using ngOnChanges, then I could apply operators to the input directly. So the input itself would be of type observable and emit the values passed to it through that. Forgive me if my terminology is wrong but that should be enough to give you an idea.

  18. niklaas commented on Oct 31, 2022

    @niklaas

    @niklaas Basically what I want is to be able to subscribe to the input rather than using ngOnChanges, then I could apply operators to the input directly. So the input itself would be of type observable and emit the values passed to it through that. Forgive me if my terminology is wrong but that should be enough to give you an idea.

    That is, you don't mean passing in an observable explicitly, right? Because if you do, this is actually possible.

  19. tayambamwanza commented on Oct 31, 2022

    @tayambamwanza

    @niklaas Basically what I want is to be able to subscribe to the input rather than using ngOnChanges, then I could apply operators to the input directly. So the input itself would be of type observable and emit the values passed to it through that. Forgive me if my terminology is wrong but that should be enough to give you an idea.

    That is, you don't mean passing in an observable explicitly, right? Because if you do, this is actually possible.

    Not my work btw it's from @tomastrajan

    Ffiy2rDXwAI2RnR

  20. csisy commented on Jan 11, 2023

    @csisy

    There was a PR mentioned in this issue #20682 (back from 2017) which provided a good solution - tho I understand why it wasn't merged. But since then, 5+ years passed. Is there any plan to introduce something similar? This issue is open for so long and we really need this feature, especially with the new inject feature introduced with v14.

  21. JeanMeche commented on Mar 16, 2023

    @JeanMeche
    Member

    This PR will land a takeUntilDetroyed operator ! (Target is V16)

  22. gavinsawyer commented on Aug 5, 2023

    @gavinsawyer

    This would be useful for accessing child components' lifecycles as well. Currently, I can't find a use for ViewChild as it's easier to avoid implementing component lifecycle hooks by just nexting a subject:

    Current approach

    Defines a reactive value (map: Subject<google.maps.Map>) with no rules and uses the template to set its value:

    <google-map
      height="100%"
      width="100%"
      [options]="mapOptions()"
      (mapInitialized)="map.next($event);"
      *ngIf="googleMapsAPILoaded()" />
    import { CommonModule, isPlatformBrowser }                                                from "@angular/common";
    import { HttpClient, HttpClientJsonpModule, HttpClientModule }                            from "@angular/common/http";
    import { Component, computed, Inject, PLATFORM_ID, signal, Signal }                       from "@angular/core";
    import { takeUntilDestroyed, toSignal }                                                   from "@angular/core/rxjs-interop";
    import { GoogleMapsModule }                                                               from "@angular/google-maps";
    import { APP_ENVIRONMENT }                                                                from "@heypoint/injection-tokens";
    import { AppEnvironment }                                                                 from "@heypoint/types";
    import { map, merge, Observable, Observer, startWith, Subject, switchMap, TeardownLogic } from "rxjs";
    
    
    @Component({
      imports:     [
        CommonModule,
        GoogleMapsModule,
        HttpClientModule,
        HttpClientJsonpModule,
      ],
      selector:    "heypoint-map",
      standalone:  true,
      styleUrls:   [
        "./map.component.sass",
      ],
      templateUrl: "./map.component.html",
    })
    export class MapComponent {
    
      public readonly googleMapsAPILoaded: Signal<boolean>;
      public readonly map:                 Subject<google.maps.Map>;
      public readonly mapOptions:          Signal<google.maps.MapOptions>;
    
      private readonly mapGeolocationEngaged: Signal<boolean>;
    
      constructor(
        @Inject(APP_ENVIRONMENT) appEnvironment: AppEnvironment,
        @Inject(PLATFORM_ID)     platformId:     object,
    
        httpClient: HttpClient,
      ) {
        this
          .googleMapsAPILoaded = isPlatformBrowser(platformId) ? toSignal<boolean>(
            httpClient.jsonp(
              "https://maps.googleapis.com/maps/api/js?key=" + appEnvironment.firebase.apiKey,
              "callback",
            ).pipe<boolean, boolean, boolean>(
              map<unknown, boolean>(
                (): boolean => true,
              ),
              startWith<boolean>(false),
              takeUntilDestroyed<boolean>(),
            ),
            {
              requireSync: true,
            },
          ) : signal<boolean>(false);
        this
          .map = new Subject<google.maps.Map>();
      }
    
    }

    Proposed approach

    Eliminates the use of subjects, integrating directly into the child component's lifecycle:

    <google-map
      #googleMap
      height="100%"
      width="100%"
      [options]="mapOptions()"
      *ngIf="googleMapsAPILoaded()" />
    import { CommonModule, isPlatformBrowser }                                       from "@angular/common";
    import { HttpClient, HttpClientJsonpModule, HttpClientModule }                   from "@angular/common/http";
    import { Component, computed, Inject, PLATFORM_ID, signal, Signal, ViewChild }   from "@angular/core";
    import { takeUntilDestroyed, toSignal }                                          from "@angular/core/rxjs-interop";
    import { GoogleMap, GoogleMapsModule }                                           from "@angular/google-maps";
    import { APP_ENVIRONMENT }                                                       from "@heypoint/injection-tokens";
    import { AppEnvironment }                                                        from "@heypoint/types";
    import { map, merge, Observable, Observer, startWith, switchMap, TeardownLogic } from "rxjs";
    
    
    // New rxjs-interop behavior:
    import { AfterViewInit } from "@angular/core";
    import { Subject }       from "rxjs";
    
    
    const afterViewInitSubject: Subject<void> = new Subject<void>();
    const afterViewInit: Observable<void> = afterViewInitSubject.asObservable(); // Just export this, or a function of it taking ComponentRef like takeUntilDestroyed.
    // ----
    
    @Component({
      imports:     [
        CommonModule,
        GoogleMapsModule,
        HttpClientModule,
        HttpClientJsonpModule,
      ],
      selector:    "heypoint-map",
      standalone:  true,
      styleUrls:   [
        "./map.component.sass",
      ],
      templateUrl: "./map.component.html",
    })
    export class MapComponent /* New rxjs-interop behavior: */ implements AfterViewInit /* ---- */ {
    
      public readonly googleMapsAPILoaded: Signal<boolean>;
      public readonly mapOptions:          Signal<google.maps.MapOptions>;
    
      private readonly map:                   Observable<google.maps.Map>;
      private readonly mapGeolocationEngaged: Signal<boolean>;
    
      @ViewChild("googleMap") private googleMap!: GoogleMap;
    
      constructor(
        @Inject(APP_ENVIRONMENT) appEnvironment: AppEnvironment,
        @Inject(PLATFORM_ID)     platformId:     object,
    
        httpClient: HttpClient,
      ) {
        this
          .googleMapsAPILoaded = isPlatformBrowser(platformId) ? toSignal<boolean>(
            httpClient.jsonp(
              "https://maps.googleapis.com/maps/api/js?key=" + appEnvironment.firebase.apiKey,
              "callback",
            ).pipe<boolean, boolean, boolean>(
              map<unknown, boolean>(
                (): boolean => true,
              ),
              startWith<boolean>(false),
              takeUntilDestroyed<boolean>(),
            ),
            {
              requireSync: true,
            },
          ) : signal<boolean>(false);
        this
          .map = afterViewInit
          .pipe<google.maps.Map>(
            switchMap<void, Observable<google.maps.Map>>(() => this.googleMap.mapInitialized.asObservable())
          );
      }
    
      // New rxjs-interop behavior:
      ngAfterViewInit() {
        afterViewInitSubject
          .next();
      }
      // ----
    
    }

    I left out declarations for mapOptions and mapGeolocationEngaged for brevity, which are reactive values that depend upon the output from mapInitialized. I'm using event listeners on the native Google Maps component to determine whether to keep updating mapOptions with the user's live location. If they swipe around or zoom, it pauses recentering the map from the geolocation stream.

  23. alxhub commented on Oct 10, 2024

    @alxhub
    Member

    👋 hey all! We recently revisited this issue in team discussions and decided that we're satisfied with the APIs we have in place today, and we don't plan to implement anything further for component lifecycle hooks. To elaborate:

    • ngOnInit - we believe that signal input()s + effect() reduce the need to even use the ngOnInit lifecycle hook. Using toObservable over signal inputs, an RxJS chain doesn't need any kind of trigger from ngOnInit to express processing of inputs.
    • ngOnChanges - not needed with signal inputs
    • ngDoCheck - not needed with signals
    • ngOnDestroy - takeUntilDestroy addresses the main use case (unsubscribing)
    • ngAfterView* & ngAfterContent* - also not necessary with signal queries & the afterNextRender API, which provide clearer (and often more performant) ways of taking action following Angular's rendering

    We will of course consider proposals for new APIs on a case-by-case basis, but I'm going to close this broader issue.

  24. angular-automatic-lock-bot commented on Nov 10, 2024

    @angular-automatic-lock-bot

    This issue has been automatically locked due to inactivity.
    Please file a new issue if you are encountering a similar or related problem.

    Read more about our automatic conversation locking policy.

    This action has been performed automatically by a bot.

  25. locked and limited conversation to collaborators on Nov 10, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions