2

On {{mut}} and 2-Way-Binding

 2 years ago
source link: https://www.pzuraq.com/on-mut-and-2-way-binding/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Home

Menu

On {{mut}} and 2-Way-Binding

Chris Garrett | 08 Oct 2019

I've been quiet on my blog for a while now, mostly because we've been heads down working on getting Octane out the door, and I made a promise to myself not to get overloaded and to wait until everything was shipped 🚢 before I started up again! However, there is a lot of work we have planned for post-Octane, some of which has been discussed in the 2019-2020 Roadmap.

In particular, I've been focusing on the plan to slim Ember down by (slowly) deprecating and removing old code that is no longer necessary due to the Octane programming model. We have a rough idea of which features should be removed, in what order, and around which versions, but a lot of the details are still up in the air and most of the core team has been too busy to actually hammer out the details in full RFCs. Still, it's good to communicate often and early, which is why we decided to open up a number of issues on the RFC repo both to signal the intent to deprecate, and as invitation for community members to help co-author these RFCs with me (if anyone is interested in taking one of these on, let me know!)

I think most Ember users were pretty prepared for the majority of these deprecations - these are non-trivial features, like Computed Properties and EmberObject, but we have been pretty loud and clear about most of them coming at some point, once the new programming model lands. The reaction to one of them, however, stood out: The mut helper.

What's wrong with mut?

If you're not familiar with the mut helper, most users know it as a quick shorthand for creating an action that updates a value:

<input 
  value={{this.value}} 
  {{action (mut this.value) value="target.value"}} 
/>

On its surface, this seems completely fine, especially for Data Down, Actions Up style patterns. It would feel very unergonomic to have to create an action on the component class just to set a value, so having a shorthand for it makes a ton of sense.

So, totally understandably, many members of the community were vocal about the idea of a deprecation:

I would prefer it if we offered a built-in ergonomic alternative before deprecating the existing solution.

The first I've heard that it was a "known antipattern", and "actively discouraged by core" was just a few months ago during a conversation about using the built in "input helpers". For a definite set time (at least from my understanding) the preferred default way to deal with form controls was to use mut.

And I only learned this week that its use is actively discouraged.

It was pretty clear after a bit of discussion that I'd made some missteps in this pre-RFC. I even (embarrassingly) wrongly claimed that we had removed mut from the official guides, when that wasn't true at all:

xScreen-Shot-2019-10-07-at-6.38.16-PM-2.png.pagespeed.ic.gbzCzV0z9L.webpAlways double check!

We dug into this at the last face-to-face meeting, and it became clear that the core team wasn't unified on the future of the mut helper after all. This was not my understanding, but as a core team member I could have done better, it's my bad 😔

The crux of the issue was that mut may still have a future in the framework, not solely as a way to generate a setter, but as a more direct replacement for 2-way-binding. Now, you may be wondering, what does mut have to do with 2-way-binding? And you may have thought, like myself, that 2-way-binding was being removed from the framework altogether - so how would it factor in here at all?

I'm writing this blog post now to apologize and clarify, and to communicate what the current state of mut and 2-way-binding is, and how these two things are fundamentally intertwined. I think historically, this is an area that has been a bit unclear, especially when Data Down, Actions Up first became a pattern within Ember, so in this post I'd like to:

  1. Clarify what is going to remain as part of the framework for the foreseeable future, as part of Octane
  2. Clarify what will be removed in the near future as we deprecated the Classic programming model
  3. Clarify what is still undecided and currently being worked through and explored by the core team.

This way, Ember Octane users can make clear and informed decisions about what patterns they choose to use in their applications for data propagation.

Not Just a Setter

Before I dig into the history behind the helper, I want to clarify some things about the behavior of mut. The setting behavior of mut is pretty well understood, in general. when you do {{mut this.foo}}, it returns a function that is a setter for this.foo. This can be a bit counterintuitive at times - for instance, when you want to set a static, known value:

Select a language!

<button {{action (mut this.language) "English"}}>English</button>
<button {{action (mut this.language) "Spanish"}}>Español</button>

But generally this is fine, so long as you have a lint rule, and understand that mut is returning a function.

Except, it isn't quite doing that.

You might have noticed if you've ever passed mut directly to a component that you always need to wrap it in an action, even if you aren't passing any values to it:

<!-- This won't (generally) work -->
<MyCheckbox 
  @checked={{this.checked}} 
  @onChange={{mut this.checked}} 
/>

<!-- This will work -->
<MyCheckbox 
  @checked={{this.checked}} 
  @onChange={{action (mut this.checked)}}
/>

This is because if you don't, mut will return the value instead:

export default class MyComponent extends Component {
  foo = 123;
}
<!-- these will both output 123 -->
{{this.foo}}
{{mut this.foo}}

This is pretty inconsistent with the way most users use mut, and it may seem confusing as to why this works at all in the first place. The answer lies in the history of mut and what its original intention was.

A migration path for 2-way-binding

mut was originally designed in the 1.13-era as a way to bridge the gap and make migrating to a world without Ember's traditional 2-way-binding much easier. The idea was that in the common case, where you want to pass a value down and have it be updated by the child component, it's actually pretty unergonomic to have to pass the value and an update function or action. It would be much nicer if we could just pass both at once. This is what mut does.

What mut actually returns is not a setter, or a value - it's both. Under the hood, it's effectively a wrapper that contains both a getter and a setter, and depending on what context it's used in, it'll either fetch the original value it references, or update it.

{{#let (mut this.foo) as |foo|}}
  <!-- When used like this, it's a getter -->
  {{foo}}

  <!-- When used like this, it's a setter -->
  <button {{action foo 123}}>Update Foo</button>
{{/let}}

Unfortunately, this idea didn't pan out quite as hoped. For one thing, this behavior of mut is confusing and not well documented. It also breaks down when attempting to interact with the value from JS code, since there's no way to distinguish when you're trying to access the value, or update it. Consider an input component with the following API:

<MyInput 
  @value={{mut this.value}} 
  @onChange={{this.updateSearchResults}} 
/>

We want to have the @value argument update automatically because we passed mut, and we want to also trigger an @onChange action whenever the value updates. Ideally we would setup an input element with an event handler that handles both, but that's not possible directly:

<!-- app/components/my-input.hbs -->
<input value={{@value}} {{on "change" this.handleChange}} />
// app/components/my-input.js
import Component from '@glimmer/component';
import { action } from '@ember/object';

export default class MyInput extends Component {
  @action
  handleChange(event) {
    // how do we update @value??

    if (this.args.onChange) {
      this.args.onChange(event);
    }
  }
}

We instead would have to pass the setter function into the handler via the template, in a very convoluted way:

<!-- app/components/my-input.hbs -->
<input 
  value={{@value}} 
  {{on "change" (fn this.handleChange (fn @value))}} 
/>
// app/components/my-input.js
import Component from '@glimmer/component';
import { action } from '@ember/object';

export default class MyInput extends Component {
  @action
  handleChange(mutValue, event) {
    mutValue(event.target.value);

    if (this.args.onChange) {
      this.args.onChange(event);
    }
  }
}

So we can see that this API would be difficult to work with on its own. In addition, it has some core issues, inherited from Ember's classic 2-way-binding behaviors, that can cause larger endemic issues in many applications.

2-way-binding: The good and the bad

First off, I don't want to bash 2-way-binding. In the simple case, it really is the best, most ergonomic solution, since it's not uncommon to want to keep your state in sync with the state of a child component.

<!-- This seems much nicer -->
<Input @value={{this.value}} />

<!-- Than this -->
<Input @value={{this.value}} @onChange={{this.updateValue}} />

This becomes much, much more apparent when you have a few different components built on top of one another, and with DDAU-only, you have to drill down both the value and a way to update the value:

<!-- sign-up-form.hbs -->

<label for="firstname">First Name</label>
<Input 
  id="firstname" 
  @value={{@firstName}} 
  @on-change={{@updateFirstName}} 
/>

<label for="lastname">Last Name</label>
<Input 
  id="lastname" 
  @value={{@lastName}} 
  @on-change={{@updateLastName}} 
/>

<label for="email">Email</label>
<EmailInput 
  id="email" 
  @value={{@email}} 
  @on-change={{@updateEmail}}/>

...

It's not hard to see how this would be tedious, especially if you have to pass these arguments through every component in your component tree. Note, for instance, how we have a custom <EmailInput> component, which means we have another layer of drilling below this! 2-way-binding would be much nicer for the simple case here.

However, some problems start to become more apparent when we start writing lots of components that rely on 2-way-binding. Specifically, there's no way to side-effect when a value changes - to do some other action that should occur when the value changes - other than by observing the property.

For instance, let's assume we wrote the above form using 2-way-binding only originally, like so:

<!-- sign-up-form.hbs -->

<label for="firstname">First Name</label>
<Input id="firstname" @value={{@firstName}} />

<label for="lastname">Last Name</label>
<Input id="lastname" @value={{@lastName}} />

<label for="email">Email</label>
<EmailInput id="email" @value={{@email}} />

...

Then later we came back to add a new feature - validation. Most of the validation could probably be done using computed values, but for email we need to make sure that the email hasn't been used yet - so we need to add a call to the server. However, we originally didn't need any actions in our 2-way-binding world, so the EmailInput doesn't have any - we now need to add an @on-change action to the component before we can add our new functionality.

In a small app, this is a trivial change, but as application size grows this can become a massive problem. This is a major part of the reason observers were so prevalent and difficult to get rid of from Ember v1-v2 era - because large parts of applications and the ecosystem were written with only 2-way-binding in mind. It was generally much easier to add an observer than to go about and add many layers of actions, so that's what happened in many cases. It's essentially a refactoring hazard.

The future of 2-way-binding

There are other technical reasons why Ember's implementation of 2-way-binding isn't great, and is something that we're moving away from. It also isn't great that it's always enabled by default, it turns out, since that means that you can never tell if a component is going to be mutating the values you pass to it, which can be quite tricky to work with.

This is why the implementation of 2-way-binding as we know it is going to be removed at some point in the future. Glimmer components already don't participate directly in 2-way-binding, and while it's still possible to mutate arguments directly by passing them downward into a 2-way-bound component such as <Input>, eventually this too will go away once classic components are fully removed from the framework. That won't be happening any time soon, but that is the eventual plan.

What is currently undecided, and actively being explored by the core team, is whether or not there can be a more direct replacement for 2-way-binding; a pattern that is mostly the same and retains the benefits, but without the pitfalls of the original design. Essentially what mut was originally supposed to be (and potentially could be in the future).

Introducing Ember Box

I think working code says much more than words, so as an example of what I mean I made ember-box:

<BoxInput @value={{box this.value}} />

box is a helper that receives a path and creates a boxed value - a reference to that value that can be passed around. Conceptually this is similar to what a Box is in other languages, like Rust, but not quite the same (in those languages it refers to a value on the heap, e.g. a memory address). It is used to solve some of the same problems as in those languages, however - passing around a reference to a value, and reading from/writing to it.

Users could implement the <BoxInput> component like so:

<input
  value={{unwrap @value}}
  {{on "input" this.onInput}}
>
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { update } from 'ember-box';

export default class Input extends Component {
  @action
  onInput({ target: { value } }) {
    update(this.args.value, value);

    if (this.args.onInput) {
      this.args.onInput(value);
    }
  }
}

Here, the <BoxInput> component expects to receive a box as an argument, and must explicitly use unwrap to get its value, and update to update it. This solves our problem with how mut would sometimes be a value and sometimes a function - instead, we let the user decide how they want to interact with the value. unwrap and update also check to see if the value is a box before they try to do anything to it, and if not they pass it through transparently. This means that the following will "Just Work":

<BoxInput @value={{this.value}} />

No need to check if the value is a box or not yourself. Additionally, boxes can be wrapped using the wrap helper:

<BoxInput @value={{wrap (box this.value) this.doSomething}} />
export default class MyComponent extends Component {
  @action
  doSomething(newValue, _super) {
    // do things

    _super(newValue);
  }
}

In this example, when we call update(this.args.value, newValue), it'll first pass newValue to the this.doSomething function. That function can trigger any side effects it wants to, and can also call _super, which will actually set the value. It can also choose not to call _super, or to call it with a different value.

Boxes allow users to opt into a 2-way-binding-like behavior with components that are written for them, and box wrappers allow intermediate levels of components to intercept boxes and side-effect or modify values as necessary when they update, without going and adding actions to each intermediate component. All of this is implemented in userland, with public APIs only, so it's fully rationalized within the framework of Data Down, Actions Up.

The future of {{mut}}

ember-box is mostly an exploration of the design space, and while it's usable today, I don't think it will really show its value unless it is adopted in the framework and used as a standard solution in general. There's still work to do to figure out if that would be worthwhile, and if it makes more sense than using and potentially expanding on mut does. After all, it's already here, and it's behavior is quite similar - the main difference is it tries to smartly unwrap the value based on context. It may be that with a subtle change in the behaviors, it could be the right solution in the end.

It's also possible that this type of pattern is too complicated in general, or too confusing, and that it may not be adopted in Ember. The mut helper may eventually be deprecated, and along with it 2-way-binding in general. These are the questions the core team is working on, and we'll let the community know once we've achieved consensus, one way or the other.

For now, I think mut will be sticking around for a while longer, so don't necessarily start rewriting all of your code just yet (though if you want to, you can consider using ember-set-helper).

There is one final thing to note: it is currently possible to introduce 2-way-binding to a Glimmer component via using the mut helper directly with named arguments:

<input 
  type="checkbox" 
  checked={{@checked}} 
  {{on "change" (fn (mut @checked) (not @checked))}} 
/>

At the moment, we recommend against using this behavior to add 2-way-binding to new Glimmer components, and we intend to add a lint rule to prevent this in the near future. Passing values directly through to classic components, on the other hand, is still ok, since that is part of the way that Glimmer components can interoperate .

<!-- 
  it's ok to pass an argument directly to 
  the built-in Input component, for example 
-->
<Input @value={{@myArgument}} />

Takeaways

So, to sum everything up:

  1. Classic 2-way-binding will be removed eventually, but a new box-like pattern that works within DDAU could be added in the future. If you have an existing application that has 2-way-bound classic components, don't necessarily rush to rewrite them just yet - there may be a more direct transition path down the road (and if not, you can always rewrite them later).
  2. Either way, DDAU is here to stay. If you're starting a new Ember application today, you can write all DDAU components and know they'll continue to work with Ember for years to come.
  3. It's still ok to use built-in components that have 2-way-binding support, such as <Input> and <Textfield>, in both existing and new applications. These components are still part of the programming model, and will remain part for the foreseeable future (It's also ok to start exploring other possible patterns for new input components, such as DDAU or box-driven versions, but don't feel like you have to re-invent the wheel here just to fit a particular pattern!)
  4. The future of mut isn't clear just yet, but it won't be removed any time soon. However, if all you need is a way to set values quickly and cleanly without making an action, consider using ember-set-helper or ember-simple-set-helper instead.
  5. Glimmer components can pass named arguments through to 2-way-bound classic components and everything will continue to work. However, they should avoid using mut directly with arguments to add new 2-way-bound APIs for the time being.

I hope this post helps to clear everything up 😄 I remember the transition from v1.13 to v2 all too well, and the confusion around 2-way-binding and DDAU at that time. We've begun to see discussions about this again now that Glimmer components are beginning to get out there, so my hope is that this will help Ember users to make a informed decision about how data should flow throughout there apps. Ultimately, there is no one-size-fits-all solution here - for existing apps, 2-way-binding is a fact that won't be going anywhere overnight, and new apps have much more flexibility.

I also would love any and all feedback you have about ember-box! So far, I'm liking the way it works, and I could see us adding something like it into Ember (or working with mut to make it line up with it's API) but I think it would benefit from some test cases and usage. I wouldn't go and adopt it in a flagship app just yet, but if you want to toy around with it in an addon or smaller app I would definitely appreciate it 😄


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK