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:
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...
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);
}
});
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
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);
}
});
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
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);
}
});
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
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);
}
}
});
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 returnfalse
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:
preventing open/close.
Now the evolution:
Start with the most straigthforward API, that is 100% DDAU:
Pros:
open
property is changed, they can implementany logic they want, no matter how weird and arbitrary.
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 hardto 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 hooksThis 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:
works. And this use-case acounts for over 95% of dropdowns in the planet.
it from happening. Arguably,
onOpen
/onClose
is slightly more expresive than anonChange
and checking if the state goes from close to open or in the other direction.Cons:
onOpen
/onClose
+ returning false to prevent the default behaviour is yetanother thing to learn.
Give the users a way of invoke actions from the outside
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:
works. And this use-case acounts for over 95% of dropdowns in the planet.
it from happening. Arguably,
onOpen
/onClose
is slightly more expresive than anonChange
and checking if the state goes from close to open or in the other direction.the internal implementation doesn't need to use observers. It is much more similar to
reacting to user interation.
case of
ember-basic-dropdown
it also has a convenienttoggle
action and areposition
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% ofpeople who wants to do something very custom.