Search code examples
aurelia

Observing immutable object via @observable


Let's assume I have a global state class that contains a TimeFrame class which has some useful properties

export class StateContainer {
    public timeframe: TimeFrame;

    public setTimeframe(startTime: Date, endTime: Date) {
         // treat this.timeframe as immutable
         this.timeframe = { startTime: startTime, endTime: endTime };
    }
}

export class TimeFrame {
    public readonly startTime: Date;
    public readonly endTime: Date;
}

Then, I need to consume this state elsewhere, so I do so via DI and then use the bindingEngine.propertyObserver to get changes on the timeframe object as one would do.

However, I would like be able to do something similar to the following, if it's possible:

@autoinject
export class Display {
    @observable
    timeFrame: TimeFrame = this.state.timeframe;      

    constructor(private state: StateContainer) {

    }

    timeFrameChanged(newValue: TimeFrame, oldValue: TimeFrame) {
         ...
         // everytime this.state.timeFrame is changed via setTimeFrame()
         // this change handler should fire, and so should any
         // bindings from this component for this.timeFrame
    }
}

However, when I do the previous, I only get timeFrameChanged(...) notifications on the inital creation, not whenever I call setTimeFrame(). Am I doing something wrong or is this not possible?


Solution

  • Yes, you are doing something wrong, I think you are misunderstanding how the observable decorator and the corresponding timeFrameChanged method are meant to be used.

    Let's take it bit by bit. Take a look at these lines:

    @observable
    timeFrame: TimeFrame = this.state.timeframe;
    
    timeFrameChanged(newValue: TimeFrame, oldValue: TimeFrame) { ... }
    

    The observable decorator tells Aurelia that whenever the property to which it is applied changes, execute the corresponding method. Unless otherwise configured, the corresponding method is nameOfInvolvedProperty + "Changed" (in this case, as you are correctly doing, timeFrameChanged).

    However, you are never actually changing the value of that property! If you actually changed that property, it would work:

    <button click.delegate="changeProp()">
      Change prop
    </button>
    

    and the VM:

    changeProp() {
      this.timeFrame = null;
      this.timeFrame = this.state.timeframe;
    }
    

    Now you'd see that the handler correctly fires.

    But in your code, this property is only ever assigned once, here:

    timeFrame: TimeFrame = this.state.timeframe;
    

    Remember, this is just dereferencing. In other words, this code tells the app to take the value of this.state.timeframe, store the momentary value of it in that variable and forget about this.state.timeframe. So it does not get updated whenever this.state.timeframe is updated.

    As described in the documentation, you can configure the observable decorator to some extent, however, to my knowledge, there is no way to configure it in such a way that set it up to observe nested properties (or maybe it's just me not knowing how to do that).

    Even if it was possible, I think a cleaner way to deal with such situations would be to use an event aggregator. This enables you to employ a subscription mechanism - whenever the value you are interested in changes, you publish an event and whichever component is interested in that change can subscribe to it and update itself accordingly.

    A simple implementation:

    import { Router, RouterConfiguration } from 'aurelia-router';
    import { autoinject } from 'aurelia-framework';
    import { observable } from 'aurelia-binding';
    import { StateContainer } from './state-container';
    import { EventAggregator, Subscription } from 'aurelia-event-aggregator';
    
    @autoinject
    export class App {
    
      subscription: Subscription = null;
    
      constructor(private state: StateContainer, private eventAggregator: EventAggregator) {
    
      }
    
      activate() {
        this.subscription = this.eventAggregator.subscribe('state:timeframe', (e) => {
          console.log('Timeframe changed!', e);
        });
      }
    
      deactivate() {
        // Make sure to dispose of the subscription to avoid memory leaks.
        this.subscription.dispose();
      }
    
      change() {
        this.state.setTimeframe(new Date(), new Date());
      }
    }
    

    And StateContainer:

    import { TimeFrame } from "./time-frame";
    import { autoinject } from "aurelia-framework";
    import { EventAggregator } from "aurelia-event-aggregator";
    
    @autoinject
    export class StateContainer {
      public timeframe: TimeFrame = null;
    
      constructor(private eventAggregator: EventAggregator) {
    
      }
    
      public setTimeframe(startTime: Date, endTime: Date) {
        // treat this.timeframe as immutable
        this.timeframe = { startTime: startTime, endTime: endTime };
    
        this.eventAggregator.publish('state:timeframe', this.timeframe);
      }
    }
    

    Hope that helps.