I have several components that have complex flows. For example, a "deploy this code" button. It might have the following flow:
- if the user is typing, the button is hidden
- after the user stops typing, the button appears, is disabled, and reads "Saving..."
- after the code is saved, the button is enabled and reads "Test & Deploy"
- after the user clicks the button, it is disabled and reads "Testing..."
- if the tests fail, the button remains disabled and reads "Tests Failed"
- if the tests pass, the button remains disabled and reads "Deploying..."
- when the deploy completes, the button changes to a "☑️ Deployed" indicator
Maintaining all of that information as a series of computed properties is error-prone and messy. Instead, I've relied on impromptu fake state machines like
STATES = {
typing: { hidden: true },
saving: { disabled: true, text: 'Saving...' },
saved: { text: 'Test & Deploy', onClick: 'runTests' },
...
}
export default Ember.Component.extend({
buttonState: STATES.typing,
actions: {
click() {
const onClick = this.get('buttonState.onClick');
if (onClick) { return this.send(onClick); }
}
}
});
<button disabled={{buttonState.disabled}} hidden={{buttonState.hidden}}>{{buttonState.text}}</button>
There are a few things that bug me about this.
One: {{buttonState.disabled}}
in the template. Does the template really need to know that that
property is state-dependent? It's possible to solve that will an extra computed property, of course:
buttonDisabled: Ember.computed.reads('buttonState.disabled')
Two: the component is in charge of the state transitions. We've moved the definitions of which state is next into the states themselves, but left how to move there to the component. We could solve this by adding methods to the states and calling them in the context of the component:
saved: {
text: 'Test & Deploy',
onClick() { this.set('buttonState', STATES.runTests); }
}
Of course, now the states need to know the name of the property that the component uses to refer to the current state. We can solve that by creating a real state machine.
According to Wikipedia's State Pattern article, a Context
has a State
,
which has multiple ConcreteState
s. In this sense, the component is the Context
. That pattern only describes a
handle()
method, but that could be generalized to at least get()
and trigger()
, which is what
ember-states offers.
We could create some computed property and action macros to help with this:
buttonState: stateMachine(BUTTON_STATES),
buttonDisabled: Ember.computed.reads('buttonState.currentState.disabled'),
buttonHidden: Ember.computed.reads('buttonState.currentState.hidden'),
actions: {
click: stateMachineAction('buttonState')
}
I'm wary of including a library that hasn't been touched in a year.
There's a pure JavaScript State Machine as well. It doesn't have Ember bindings, though we could use those same macros to add necessary bindings. For example,
function stateMachine(definition) {
return Ember.computed({
get(propertyName) {
definition = $.clone(definition, true);
stateChanged = this.notifyPropertyChange.bind(this, propertyName);
if (const originalOnafterevent = defintion.callbacks.onafterevent) {
definition.callbacks.onafterevent = function() {
originalOnafterevent();
stateChanged();
};
} else {
definition.callbacks.onafterevent = stateChanged;
}
return StateMachine.create(definition);
}
});
}