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:
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:
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:
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:
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.
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:
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):
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:
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:
That implies that the controller can be really slimmed down to the saveBand
action:
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:
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:
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:
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:
Nothing else needs to change for the cursor position weirdness to go away:
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