Skip to content

Instantly share code, notes, and snippets.

@simonihmig
Last active December 23, 2016 19:02
Show Gist options
  • Save simonihmig/f4cada751b89b192f608bcb83ed1c22a to your computer and use it in GitHub Desktop.
Save simonihmig/f4cada751b89b192f608bcb83ed1c22a to your computer and use it in GitHub Desktop.
DDAU for public properties with common behaviour

Patterns for common behaviour of Ember components without violating DDAU

DDAU (data down action up) is the common pattern for creating ideomatic components in modern Ember.js. This prevents (unexpectedly) changing your application state in components, and enables you to put your state management logic to where it belongs: the component/controller/service that "owns" that state.

But what to do if you want to enable controlling some specific part of your component for some rare cases where this is needed, but have a default common behaviour (just UI related) for controlling that property where this is not needed in most cases.

Let's look at this with an example: we want to create a component for a simple dropdown button. It has a open property that controls the state of its dropdown menu being shown: when you click on the button that property is set to true which renders the dropdown menu, and when you click again outside of it it will set it to false and thus hide the menu. So far so good, this is the default behaviour you would expect, and that is fine as long as this property is declared as private.

But what if you want to enable your application to programmatically control opening or closing the dropdown menu. At first this seems easy, just declare the open property as public and create a binding to that property in your component invocation:

{{drop-down open=someOtherProperty}}

But then when the default behaviour remains in place, with Ember's two-way bindings changing the open property within the component because of some click events will propagate that change to someOtherProperty, so changing your application state within the dropdown and thus violating the DDAU principle.

So our problem in a nutshell: we want the component to have its default behaviour for 99% of the cases were we do not need full control, but allow this control for some rare cases without violating DDAU. And a common pattern agreed upon by the Ember community for solving that problem seems to be missing, or at least I have not been able to identify one so far.

So here are my candidates...

A) Pure DDAU

The component does not set the public properties at all, thus all property bindings effectively become one-way.

export default Ember.Component.extend({
  open: false,
  onChange: () => {},
  _open() {
    this.get('onChange')(true);
  }
});
{{drop-down open=someOtherProperty onChange=(action (mut someOtherProperty))}}

Pros:

  • Purest DDAU approach

Cons:

  • delegates the handling of the open state to the user of the component, even when not required
  • requires a lot of boilerplate for each component use

B) Apply default behaviour only when not controlling property

The component checks wheather the open property is used, and if so the user of the component takes over control, otherwise the default behaviour is applied. This is similar to what https://github.com/jquense/uncontrollable does for React components.

export default Ember.Component.extend({
  open: false,
  onChange: () => {},
  _open() {
    if (this.attrs.open === undefined) {
      this.set('open', true);
    }
    this.get('onChange')(true);
  }
});
Uncontrolled: {{drop-down}}
Controlled: {{drop-down open=someOtherProperty onChange=(action (mut someOtherProperty))}}

Pros:

  • Allows a controlled (matches case A) and an uncontrolled (default behaviour, no boilerplate required) way of using the component

Cons:

  • Just by using the open property it looses its default behaviour, which might be unexpected by the user

C) Provide default behaviour with an overridable action

The default behaviour is implemented in the default action, that can be overriden.

export default Ember.Component.extend({
  open: false,
  onChange: (state) => this.set('open', state),
  _open() {
    this.get('onChange').call(this, true);
  }
});
Uncontrolled: {{drop-down}}
Controlled: {{drop-down open=someOtherProperty onChange=(action (mut someOtherProperty))}}
Two-way binding, violates DDAU: {{drop-down open=someOtherProperty}}

Pros:

  • Allows a controlled (matches case A) and an uncontrolled (default behaviour, no boilerplate required) way of using the component
  • Probably quite easy to reason about
  • Allows two-way binding by not using a custom action handler, if that is what the user desires (is this a pro or rather a con? :))

Cons:

  • Can accidentally violate DDAU if user binds to open property but forgets to add custom action handler
  • If the user wants to make use of the onChange action for some other purposes than controlling the UI state, he/she is still forced to control the UI state
  • If he/she forgets to do so the component will accidentally loose its expected behaviour

D) Disable default behaviour by action's return value

The default behaviour remains in place as long as you do not return false from the action, which signals that you want to take over control.

export default Ember.Component.extend({
  open: false,
  onChange: () => {},
  _open() {
    if (this.get('onChange')(true) !== false) {
      this.set('open', true);
    }
  }
});
Uncontrolled: {{drop-down}}
Controlled: {{drop-down open=someOtherProperty onChange=(action "dropdown")}}

Pros:

  • Allows a controlled (matches case A) and an uncontrolled (default behaviour, no boilerplate required) way of using the component
  • Allows two-way binding by not returning false, if that is what the user desires (is this a pro or rather a con? :))
  • Does not accidentally loose its expected behaviour

Cons:

  • Can violate DDAU if user binds to open property but forgets to return false
@bendemboski
Copy link

bendemboski commented Dec 21, 2016

I like the pattern of optionally registering actions with the user of the component:

// drop-down.js
export default Ember.Component.extend({
  open: false,

  init() {
    this._super(...arguments);
    let registerActions = this.get('registerActions');
    if (registerActions) {
      registerActions({
        isOpen: () => this.get('open'),
        setOpen: (open) => this.setOpen(open)
      });
    }
  },

  setOpen(open) {
    this.set('open', open);
  },

  actions: {
    open() {
      this.setOpen(true);
    },
    close() {
      this.setOpen(false);
    }
  }
});
// parent-component.js
export default Ember.Component.extend({
  actions: {
    registerDropdownActions({ isOpen, setOpen }) {
      this.set('isDropdownOpen', isOpen);
      this.set('setDropdownOpen', setOpen);
    },

    toggleDropdown() {
      let isOpen = this.get('isDropdownOpen')();
      this.get('setDropdownOpen')(!isOpen);
    }
  }
});
{{! parent-component.hbs }}
Uncontrolled: {{drop-down}}
Controlled: {{drop-down registerActions=(action 'registerDropdownActions')}}

@bendemboski
Copy link

bendemboski commented Dec 21, 2016

Gets stickier if the child component if wrapped in an {{#if}} block or something, but you can always provide an unregisterActions callback (or something).

@cibernox
Copy link

In this kind of situations the goal, as I see it, it's trying to be as DDAU as posible
without compromising the verbosity of the public API too much.

The balance is hard to find tho. However, and this is something that grew on me over the
years, more and more I favour explicitness even if that means some verbosity. I'd rather
type a few more chars every time I invoke a component it that makes a component extremely
simple to understand.

Now, to the example in hand, that happens to be one I know pretty well, I'll explain
the approach I've built in ember-basic-dropdown, and why.

Premises:

  • The dropdown opens en clicked on the trigger, and closes when clicked outside.
  • Users might need to control the component from the outside.
  • The more DDAU, the better.
  • I wanted to be low lever and allow users to do whatever they please, including
    preventing open/close.

Now the evolution:

Start with the most straigthforward API, that is 100% DDAU:

{{drop-down open=isOpen onChange=(action (mut isOpen))}}

Pros:

  • 100% DDAU
  • Dead simple to understand. There is literally 0 room for confussion about how this works.
  • Since the user controls how and when the open property is changed, they can implement
    any logic they want, no matter how weird and arbitrary.
  • Users can also open and close the component from the outside

Cons:

  • Opening and closing is literally the only thing that 100% of dropdowns have in common.
    That means that 100% of the times anyone uses this dropdown. Making this logic explicit
    has the higher level of annoyance than any other option in the dropdown.

  • The same way that a binding propagation-only ({{drop-down isOpen=dropdownIsOpen}}) makes hard
    to react to changes in the open state of the dropdown from the outside, so users have to
    resort to observers like Ember.observer('dropdownIsOpen', function() { /* do other stuff */ }),
    propagating bindings from the outside to the inside, makes hard for the internals of the
    component to react to changes in the open state of the component that come from the
    outside to perform non-binding work, forcing YOU to use observers inside your component.
    This is far mor acceptable than making the end users use observers, but still annoying.
    One example non-binding work that a dropdown has to do when it gets opened is to calculate
    the X/Y coordinates of the content when it's using absolute positioning, which all
    dropdowns should support.

Because of those two cons, I decided that I preferred an approach that didn't force the user
to be that verbose in the 100% of the cases, and didn't make the component use observers inside.

Hide the open state and add open/close hooks

{{drop-down}} {{! basic usage }}
{{drop-down onOpen=(action "aboutToOpen") onClose=(action "aboutToClose")}}

This approach makes the API entirely dry in the most common use case, keeping the open
state as something 100% private. With the addition of 2 actions that are called prior to
the dropdown opening or closing, the user can still react to those changes, or even prevent
them returning false from those actions.

Pros:

  • 100% DDAU
  • The user doesn't have to worry about handing the open state of the dropdown, it just
    works. And this use-case acounts for over 95% of dropdowns in the planet.
  • The users still hold the ability to react when the component opens/closes, or prevent
    it from happening. Arguably, onOpen/onClose is slightly more expresive than an
    onChange and checking if the state goes from close to open or in the other direction.

Cons:

  • Users cannot open/close the dropdown from the outside
  • Arguably, onOpen/onClose + returning false to prevent the default behaviour is yet
    another thing to learn.

Give the users a way of invoke actions from the outside

{{drop-down registerAPI=(action (mut dropdownRemote))}}
onFoo() {
  this.get('dropdownRemote').open();
}

In this approach, the component, on init, creates an object that contain some functions
that allow to open/close the dropdown from the outside. That object is passed to the parent
context. If the users cares about that kind of behaviour, they can store that remote
controller to open/close the dropdown at will.

Pros:

  • 100% DDAU
  • The user doesn't have to worry about handing the open state of the dropdown, it just
    works. And this use-case acounts for over 95% of dropdowns in the planet.
  • The users still hold the ability to react when the component opens/closes, or prevent
    it from happening. Arguably, onOpen/onClose is slightly more expresive than an
    onChange and checking if the state goes from close to open or in the other direction.
  • The users can programatically control the dropdown from the outside using the remote controller.
  • Since the remote controller opens/closes the dropdown programatically and not though bindings,
    the internal implementation doesn't need to use observers. It is much more similar to
    reacting to user interation.
  • Open/close the component will
  • This remove controller can have actions to perform more things than open/close. In the
    case of ember-basic-dropdown it also has a convenient toggle action and a reposition
    one, that can be used to recalculate X/Y coordinates when, by exaple, the trigger changes
    its dimmensions.

Cons:

  • registerAPI expands the surfaceAPI of the component, although just for the 5% of
    people who wants to do something very custom.

@cibernox
Copy link

cibernox commented Dec 22, 2016

This is more or less the way the API of ember-basic-dropdown evolved.
My option (option E I suppose) is a combination of A (pure DDAU) + D (return false to prevent default behaviour) + give the user a way of invoking actions from the outside + hide private state (open is not directly set by the user)

@simonihmig
Copy link
Author

@cibernox Thanks a lot for taking the time to comment on this in such verbosity!

To be honest I had a look at ember-power-select and ember-basic-dropdown before writing this to see if I can deduce something from it. :) But I missed the registerAPI part, it seems to be undocumented right? Just found it now when looking at the source...

Anyways, I see your option E is pretty similar in principle to what @bendemboski proposed in his comment above and during some chat yesterday on Slack. We can call it "Actions Down", can we?

It changes the "down" part of interacting with the component from the declarative flow of data that Ember manages for you to an imperative API. While that might be appropriate for that case, I see a downside here, that I think might be pretty severe!

That is that once the public API of the component has been registered with the parent (component/controller), the parent then holds a reference to the actions that is not bound to the lifecycle of the component. I.e. when the component is destroyed but not the parent, then it is still holding references to it. Which means:

  • as the actions will hold a reference to the component through their this scope, that component will never be garbage collected (and with that all other stuff that the component might still reference) as long as the references exist
  • this problem gets even more serious when the parent holding the API references is a controller, as controllers are not disposed (singletons)
  • could be solved by some unregister API, but that would add to the API surface area, and probably be error prone (requires user to take care of)
  • the actions might still be called from the parent even if the component is already destroyed. So required if (this.get('isDestroyed') return guards everywhere

All this does not happen when relying on "data down" instead of "actions down", as Ember will manage the flow of data and setting up/tearing down the bindings during the component`s lifecycle.

So I am not 100% sure this is the way to go for me...

@simonihmig
Copy link
Author

As a side note: it seems Gist does not send out notifications on new comments/mentions: isaacs/github#21
That is so lame! So beware when taking part here, will have to revisit!?

@fsmanuel
Copy link

I'm not sure if this covers 100% of what you are looking for but I would solve the problem with a computed property.
I created this ember twiddle (gist)

Here are the importend lines:

{{drop-down isOpen=isOpen}}
export default Ember.Component.extend({
  open: computed('isOpen', {
    get() {
      return getWithDefault(this, 'isOpen', false);
    },
    set(key, value) {
      return value;
    }
  })
});

@simonihmig
Copy link
Author

simonihmig commented Dec 23, 2016

@fsmanuel Indeed it seems to do everything I was looking for, with so little effort. Awesome!

The key seems to be the setter function there, which at first sight seems to do nothing. But if you omit it, it actually behaves totally different, in that the CP won't react to changes to isOpen anymore once you set the property.

If it's like this:

export default Ember.Component.extend({
  open: computed('isOpen', {
    get() {
      return getWithDefault(this, 'isOpen', false);
    }
  })
});

The CP would react to changes to isOpen until you set it (by clicking on your component in the Twiddle), then it looses its "binding" and only holds the value that was last set. I really thought that CPs always behave like that. But it seems as soon as you add the "useless" setter method, this changes so that even after setting the CP it will still react to changes of isOpen.

Was not aware of this. Is this actually documented somewhere so we can rely an that behaviour?

In your Twiddle the isOpen controller property cannot be updated bases on the component state, but that's what an onChange action of the component would solve anyways, so it seems indeed this could be everything I need! :)

@simonihmig
Copy link
Author

For the record: the above mentioned concerns that the differing behaviour of CPs with and without a setter might be not documented are actually void: it's here: http://emberjs.com/api/classes/Ember.ComputedProperty.html:

You can overwrite computed property with normal property (no longer computed), that won't change if dependencies change, if you set computed property and it won't have setter accessor function defined.

@fsmanuel
Copy link

fsmanuel commented Dec 23, 2016

@simonihmig It should be easy to make it a computed property:

function readsWithDefault(dependentKey, defaultValue) {
  return computed(dependentKey, {
    get() {
      return getWithDefault(this, dependentKey, defaultValue);
    },
    set(key, value) {
      return value;
    }
  });
}

export default Ember.Component.extend({
  open: readsWithDefault('isOpen', false)
});

@cibernox
Copy link

@simonihmig your concerns about leaking are valid. The component should nullify the remote controller in the willDestroy hook to avoid that. The reason for this approach is that there is more actions than open/close, some of which like reposition is not something you can trigger changing a property.

I needed a way of triggering actions from the outside, and since I had that, I put everything there. Although I do provide an initiallyOpened=true/false option to render the dropdown open in the first place, but it never updates again, from that point on the component holds its own state.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment