ember.jsember-octane

How to replace `@computed` with setter returning new value with new native setters?


Problem

I've often used this kind of computed properties where the setter simply returns the new value :

  @computed('args.myValue')
  get myValue() {
    return this.args.myValue;
  }
  set myValue(newValue) {
    return newValue; // <==== this is no longer valid with native setter
  }

This does few things :

  1. Set initial value to args.myValue
  2. Allow to change the value (typically through an <Input @value={{this.myValue}} />)
  3. Restore the default value when args.myValue changes

The problem comes with native setters which can't return any value.

Notice I could probably find a "hackish" solution but I'd like to have code that follows new EmberJS conventions in order to avoid painfull later updates.

Things I tried

Manual caching

  @tracked _myValue = null;

  get myValue() {
    return this._myValue || this.args.myValue;
  }
  set myValue(newValue) {
    this._myValue = newValue;
  }

This does not work because _myValue is always set after the first myValue=(newValue). In order to make it work, there should be some kind of observer which resets it to null on args.myValue change.

Sadly, observers are no longer part of EmberJS with native classes.

{{unbound}} helper

<Input @value={{unbound this.myValue}} />

As expected, it does not work because it just doesn't update myValue.

{{unbound}} helper combined with event.target.value handling

<Input @value={{unbound this.myValue}} {{on "keyup" this.keyPressed}} />
  get myValue() {
    return this.args.myValue;
  }

  @action keyPressed(event) {
    this.doStuffThatWillUpdateAtSomeTimeMyValue(event.target.value);
  }

But the Input is still not updated when the args.myValue changes.

Initial code

Here is a more concrete use example :

Component

// app/components/my-component.js

export default class MyComponent extends Component {

  @computed('args.projectName')
  get projectName() {
    return this.args.projectName;
  }
  set projectName(newValue) {
    return newValue; // <==== this is no longer valid with native setter
  }

  @action
  searchProjects() {
    /* event key stuff omitted */
    const query = this.projectName;
    this.args.queryProjects(query);
  }
}
{{! app/components/my-component.hbs }}

<Input @value={{this.projectName}} {{on "keyup" this.searchProjects}} />

Controller

// app/controllers/index.js

export default class IndexController extends Controller {

  get entry() {
    return this.model.entry;
  }

  get entryProjectName() {
    return this.entry.get('project.name');
  }

  @tracked queriedProjects = null;

  @action queryProjects(query) {
    this.store.query('project', { filter: { query: query } })
      .then((projects) => this.queriedProjects = projects);
  }

  @action setEntryProject(project) {
    this.entry.project = project;
  }
}
{{! app/templates/index.hbs }}

<MyComponent 
  @projectName={{this.entryProjectName}} 
  @searchProjects={{this.queryProjects}} />

When the queriedProjects are set in the controller, the component displays them.

When one of those search results is clicked, the controller updates the setEntryProject is called.


Solution

  • According to this Ember.js discussion :

    Net, my own view here is that for exactly this reason, it’s often better to use a regular <input> instead of the <Input> component, and to wire up your own event listeners. That will make you responsible to set the item.quantity value in the action, but it also eliminates that last problem of having two different ways of setting the same value, and it also gives you a chance to do other things with the event handling.

    I found a solution for this problem by using standard <input>, which seems to be the "right way" to solve it (I'll really appreciate any comment that tells me a better way) :

    {{! app/components/my-component.hbs }}
    
    <input value={{this.projectName}} {{on "keyup" this.searchProjects}} />
    
    // app/components/my-component.js
    
    @action
    searchProjects(event) {
      /* event key stuff omitted */
      const query = event.target.value;
      this.args.queryProjects(query);
    }
    

    If I needed to keep the input value as a property, I could have done this :

    {{! app/components/my-component.hbs }}
    
    <input value={{this.projectName}} 
      {{on "input" this.setProjectQuery}} 
      {{on "keyup" this.searchProjects}} />
    
    // app/components/my-component.js
    
    @action setProjectQuery(event) {
      this._projectQuery = event.target.value;
    }
    
    @action
    searchProjects( {
      /* event key stuff omitted */
      const query = this._projectQuery;
      this.args.queryProjects(query);
    }
    

    EDIT

    Notice the following solution has one downside : it does not provide a simple way to reset the input value to the this.projectName when it does not change, for example after a focusout.

    In order to fix this, I've added some code :

    {{! app/components/my-component.hbs }}
    
    <input value={{or this.currentInputValue this.projectName}}
      {{on "focusin" this.setCurrentInputValue}}
      {{on "focusout" this.clearCurrentInputValue}}
      {{on "input" this.setProjectQuery}} 
      {{on "keyup" this.searchProjects}} />
    
    // app/components/my-component.js
    // previous code omitted
    
    @tracked currentInputValue = null;
    
    @action setCurrentInputValue() {
      this.currentInputValue = this.projectName;
    }
    
    @action clearCurrentInputValue() {
      this.currentInputValue = null;
    }