The tale of two bindings

22 October 2015

Two weeks ago I had a presentation called "Complex component design" at the Global Ember Meetup.

When I had given the title and abstract of this presentation, I had wanted to speak about how to implement more involved, nested components in Ember 2 (but actually, more like 1.13+), which is a fascinating and complex (pun intended) topic. I had something like a highly reusable autocomplete input component in mind something that I had decided to explore further.

When I sat down to think about it, I had realized there is a related and equally fascinating topic, that of communication between components that live on the same page. As everything is soon becoming a component in Ember, the two are very similar. A difference is that communicating components in an app do not need such a high degree of flexibility as a reusable component (like an Ember addon). In any case, it does not hurt, since making them flexible facilitates their reuse.

In this post, I'll show an example of simple component communication and focus on how moving from two-way bindings to one-way bindings changes that. Spending some time on playing around with this, I was very pleasantly surprised in what this shift enables.

The example

If you know me a bit, you'd expect the "app" to be Rock & Roll themed and you'd be right. I reduced the app in the book to a very simple interface where you can pick a band and then edit it on a form:

Edit band on a form

In the remainder of the post, we'll see different implementations to achieve the validation and saving of the band. There will be 3 scenarios: the traditional one, using two-way bindings, the Glimmer version, using one-way bindings, DOM events and Data Down, Actions up (DDAU) and finally 1WAY Deluxe™: adding a few features on top of the second scenario that one-way bindings make easy (or possible at all).

Take 1: Traditional, two-way bound

Ignoring the list of bands on the left, the template belonging to the band route, where the band can be edited, contains the band-form (on the right of the screenshot), and some minimal markup. We pass in the band object, the on-save and the on-star-click closure actions to the band form:

1<div class="panel panel-default band-panel">
2  <div class="panel-body">
3    {{band-form band=model
4                errors=errors
5                on-save=(action "saveBand")
6                on-star-click=(action "updateRating")}}
7  </div>
8</div>

The controller has these actions, sets up the errors object and contains the validation logic. The hasErrors property will be true if the band's name is empty:

 1import Ember from 'ember';
 2
 3export default Ember.Controller.extend({
 4  hasValidName: Ember.computed.notEmpty('model.name'),
 5  hasErrors: Ember.computed.not('hasValidName'),
 6
 7  setupErrors: Ember.on('init', function() {
 8    this.set('errors', Ember.Object.create());
 9  }),
10
11  validate() {
12    this.set('errors.name', this.get('hasValidName') ? null : "Name is required.");
13  },
14
15  actions: {
16    updateRating(params) {
17      const { item: band, rating } = params;
18      band.set('rating', rating);
19    },
20
21    saveBand() {
22      this.validate();
23      if (this.get('hasErrors')) {
24        return;
25      }
26
27      const band = this.get('model');
28      return band.save().then(() => {
29        console.log("Band is saved");
30      });
31    }
32  }
33});

Upon validation, the errors are set but this is only needed to be able to show the error in the template. this.get('hasErrors') is already true if the band's name is an empty string.

The missing piece is the band-form template:

 1<div class={{if errors.name "form-group has-error" "form-group"}}>
 2  <label for="band-name">Name</label>
 3  {{input type="text" class="form-control" id="band-name" value=band.name}}
 4</div>
 5<div class="form-group">
 6  <label for="band-year">Formed in</label>
 7  {{input type="text" class="form-control" id="band-year" value=band.year}}
 8</div>
 9<div class="form-group">
10  <label for="band-rating">Rating</label>
11  {{star-rating id="band-rating" item=band rating=band.rating on-click=on-star-click}}
12</div>
13<button type="submit" class="btn btn-primary pull-right" {{action "saveBand"}}>Submit</button>

It uses the input helper which established two-way bindings between the value of the input and the property that was passed to it. When the user modifies the input, band.name changes in sync. Since band in the component is the model of the controller, the band name in the list changes as the name is edited:

Two-way bindings in action

In this scenario, communication between the top-level component (controller, if you will) and the band form is quite blunt. As data is two-way bound, there is no simple, "in-framework" way of not changing the name in the list when the name on the form is modified. There is shared state and the components do not act via messages: they pull two ends of the same string.

(In cases where you had to prevent that in the pre-Glimmer era, you had to resort to using a separate property, like band.newName, or using BufferedProxy.)

So let's take a step forwards and see how this can be improved.

Take 2: One-way bound with DDAU

We'll first replace the two-way binding with a one-way one and manually synchronize the upstream direction using DDAU. It will not seem like a big gain but it will enable us to go further and attain 1WAY Deluxe™.

The top-level template only needs a slight change. We no longer pass in an on-star-click action but instead an on-update one. This will serve for the upstream synchronization, setting what changed in the component on the band object (the model) of the controller.

1<div class="panel panel-default band-panel">
2  <div class="panel-body">
3    {{band-form band=model
4                errors=errors
5                on-save=(action "saveBand")
6                on-update=(action "updateBandProperty")}}
7  </div>
8</div>

In accordance, the only thing that has changed in the controller is that the updateStarRating action has been replaced by updateBandProperty. This is the manual syncing:

 1export default Ember.Controller.extend({
 2  (...)
 3  actions: {
 4    updateBandProperty(property, value) {
 5      this.get('model').set(property, value);
 6    },
 7
 8    (...)
 9  }
10});

In the template, the two-way bound input helpers are out, substituted by regular input tags. We attach event listeners to them which will trigger the synchronization proces (I wrote a post about how that works a few months ago):

 1<div class={{if errors.name "form-group has-error" "form-group"}}>
 2  <label for="band-name">Name</label>
 3  <input type="text" class="form-control" id="band-name" value={{band.name}}
 4    oninput={{action "nameDidChange" value="target.value"}} />
 5</div>
 6<div class="form-group">
 7  <label for="band-year">Formed in</label>
 8  <input type="text" class="form-control" id="band-year" value={{band.year}}
 9    oninput={{action "yearDidChange" value="target.value"}} />
10</div>
11<div class="form-group">
12  <label for="band-rating">Rating</label>
13  {{star-rating id="band-rating" item=band rating=band.rating on-click=(action "ratingDidChange")}}
14</div>
15<button type="submit" class="btn btn-primary pull-right" {{action "saveBand"}}>Submit</button>

nameDidChange, yearDidChange and ratingDidChange all end up calling the passed in closure action, on-update, with the name of the property that has changed and its new value. This calls updateBandProperty in the controller we already saw:

 1import Ember from 'ember';
 2
 3export default Ember.Component.extend({
 4  tagName: 'form',
 5  band: null,
 6  errors: null,
 7  "on-update": null,
 8  "on-save": null,
 9
10  actions: {
11    saveBand() {
12      this.attrs['on-save']();
13    },
14
15    nameDidChange(value) {
16      this.attrs['on-update']('name', value);
17    },
18    yearDidChange(value) {
19      this.attrs['on-update']('year', value);
20    },
21    ratingDidChange(params) {
22      const { rating } = params;
23      this.attrs['on-update']('rating', rating);
24    },
25  }
26});

From the outside, the app works just as before. The band name changes in the list as we edit it in the text field:

One-way bindings in action

However, we know that under the hood our code took control of propagating data changes. We have undone the string that kept the two sides (two components) tied strongly together. In the third and final iteration, we'll leverage that to move validation where it belongs and add a micro-feature.

Take 3: 1WAY Deluxe™

Now, for the cool part. Now that we're free to change band-related properties on the component without affecting the properties of the band object (the model of the controller), we no longer have a shared state.

The first thing we'll do is to move the validation into the band-form component. band-form will be also less chatty. It will only send property updates when the form is submitted. That means we don't need to pass in the errors object or an on-update action:

1<div class="panel panel-default band-panel">
2  <div class="panel-body">
3    {{band-form band=model on-save=(action "saveBand")}}
4  </div>
5</div>

That implies that the controller can be really slimmed down to the saveBand action:

 1import Ember from 'ember';
 2
 3export default Ember.Controller.extend({
 4  actions: {
 5    saveBand(properties) {
 6      const band = this.get('model');
 7      band.setProperties(properties);
 8
 9      return band.save().then(() => {
10        console.log("Band is saved");
11      });
12    }
13  }
14});

Note how the input field values in the band-form template are now bound to properties on the component as opposed to that of the passed in band object:

 1<div class={{if errors.name "form-group has-error" "form-group"}}>
 2  <label for="band-name">Name</label>
 3  <input type="text" class="form-control" id="band-name" value={{name}}
 4    oninput={{action "nameDidChange" value="target.value"}} />
 5</div>
 6<div class="form-group">
 7  <label for="band-year">Formed in</label>
 8  <input type="text" class="form-control" id="band-year" value={{year}}
 9    oninput={{action "yearDidChange" value="target.value"}} />
10</div>
11<div class="form-group">
12  <label for="band-rating">Rating</label>
13  {{star-rating id="band-rating" item=band rating=rating on-click=(action "ratingDidChange")}}
14</div>
15<div class="form-group button-panel">
16  <button type="submit" class="btn btn-primary pull-right" {{action "saveBand"}}>Submit</button>
17  <button type="button" class="btn btn-danger pull-right" {{action "reset"}}>Reset</button>
18</div>

Little else has changed but a second button, Reset, already gives you a taste of things to come. Let's see the component definition:

 1import Ember from 'ember';
 2
 3export default Ember.Component.extend({
 4  tagName: 'form',
 5  band: null,
 6  "on-save": null,
 7
 8  name:    null,
 9  year:    null,
10  rating:  null,
11  errors:  null,
12
13  // Validation code comes here, copied verbatim from the controller
14
15  resetOnInit: Ember.on('init', function() {
16    this.resetFromBand();
17  }),
18
19  resetFromBand() {
20    ['name', 'year', 'rating'].forEach((field) => {
21      const valueInBand = this.get('band').get(field);
22      this.set(field, valueInBand);
23    });
24  },
25
26  actions: {
27    saveBand() {
28      this.validate();
29      if (this.get('hasErrors')) {
30        return;
31      }
32
33      return this.attrs['on-save'](this.getProperties(['name', 'year', 'rating']));
34    },
35
36    nameDidChange(value) {
37      this.set('name', value);
38    },
39    yearDidChange(value) {
40      this.set('year', value);
41    },
42    ratingDidChange(params) {
43      const { rating } = params;
44      this.set('rating', value);
45    },
46    reset() {
47      this.resetFromBand();
48    }
49  }
50});

I cut out the code responsible for validation since that has just been brought over from the controller.

The interesting stuff happens in resetFromBand which is both called when the component comes to life and when the Reset button is clicked. It copies the name, year and rating properties of the band onto those of the component, effectively resetting them to their original value. That's the only reason we still need to pass in the band object into the component.

Also notice how the name and the rating are not updated on the band object as we interact with the form:

1WAY Deluxe in action

Having the data validated by the form acts as a guard. The save action is only triggered if the data was found to be valid. It is only then that the form's data will overwrite that of the band object. Data flows both ways but in a very controlled way.

To take this further, thanks to closure actions, we could even display an error in the band-form component if the save operation fails on the controller:

 1export default Ember.Component.extend({
 2  (...)
 3  actions: {
 4    saveBand() {
 5      this.validate();
 6      if (this.get('hasErrors')) {
 7        return;
 8      }
 9      const properties = this.getProperties(['name', 'year', 'rating']);
10      return this.attrs['on-save'](properties)
11        .catch((error) => {
12          this.set('errors.base', error.get('message'));
13        });
14    },
15
16    (...)
17  }
18});

UPDATE: The bug explained below was fixed in Ember 2.3.1 so 1WAY Deluxe™ just works, there's no need for the Take 4 solution and ember-one-way-input. The add-on has consequently been deprecated.

Take 4: 1WAY Deluxe™ without input cursor wackiness

The above 1WAY Deluxe™ has a bug that Robert Jackson pointed out and that I did not realize while building the demo app. The cursor in the text field always jumps back at the end of the text after each change:

1WAY Deluxe input wackiness

During the Glimmer rewrite he spent a lot of time tracking down that bug, the result of which is the ember-one-way-input Ember addon.

So that's what we should use instead of regular input tags. We first install the addon with ember install ember-one-way-input. That gives us a one-way-input component that takes an update action which will be triggered at each change of the input's value (more precisely, on both change and input events).

Let's replace the input tags in the component's template:

 1<div class={{if errors.name "form-group has-error" "form-group"}}>
 2  <label for="band-name">Name</label>
 3  {{one-way-input type="text" class="form-control" id="band-name" value=name
 4    update=(action "nameDidChange")}}
 5</div>
 6<div class="form-group">
 7  <label for="band-year">Formed in</label>
 8  {{one-way-input type="text" class="form-control" id="band-year" value=year
 9    update=(action "yearDidChange")}}
10</div>
11<div class="form-group">
12  <label for="band-rating">Rating</label>
13  {{star-rating id="band-rating" item=band rating=rating on-click=(action "ratingDidChange")}}
14</div>
15<div class="form-group button-panel">
16  <button type="submit" class="btn btn-primary pull-right" {{action "saveBand"}}>Submit</button>
17  <button type="button" class="btn btn-danger pull-right" {{action "reset"}}>Reset</button>
18</div>

Nothing else needs to change for the cursor position weirdness to go away:

1WAY Deluxe input without cursor wackiness

Thank you to Robert Jackson and Toran Billups for spotting this and pointing me to the solution.

Conclusion

I'm really excited and curious about how many things this makes possible. As I said in my presentation, we're (but surely: I am) only figuring out both the possibilities "managed" one-way bindings open up and the best way to work with them. So if you have thoughts or insights, please do share them in the comments.

NOTE: I published the demo app of this post on Github.

Share on Twitter