can-define
can-define copied to clipboard
Proposal: Observable finite state machine
This was discussed on a recent live stream (19:10) and a previous live stream (33:18).
Problem: I found ViewModels with many properties to be error prone when switching between logical states in the ViewModel. Forgetting which properties to update when the state was updated was problematic.
Example API:
import DefineGraph from "can-define/graph/graph";
const H20 = DefineGraph.extend('H20', {
initial: {
// actions must reference existing states
frozen: 'ice',
boiling: 'steam',
// DefineMap
values: {
temp: '60F'
}
},
ice: {
boiling: 'steam',
warm: 'initial',
values: {
temp: '32F'
}
},
steam: {
cool: 'initial',
frozen: 'ice',
values: {
temp: '212F'
}
}
})
const water = new H20()
water.temp //-> '60F'
// 'is' switches from once state to the next
// and the ViewModel would then be set to values defined in that state's `values` property.
// (Not happy with this function name either)
water.is('frozen')
water.state //-> 'ice'
water.temp //-> '32F'
// 'to' also allows to the developer to set the values of the new state. This would be a merge.
water.is('warm', { temp: '75F' })
water.state //-> 'default'
water.temp //-> '75F'
@mjstahl Thanks! I've got a lot of random thoughts on this.
Throat clearing on how to do FSM-ish stuff in define-map right now
The ATM example is a close approximation to the technique I normally use. CanJS usually "flips" the state machine to focus on the "extended state variables". So instead of focusing on the transitions, we derive the "state" from the variables. It looks like this:
DefineMap.extend("H20",{
get state(){
if(this.temp < 32 } return "freeze";
if(this.temp < 212 } return "default";
return "steam"
},
temp: {type: "number", default: 60}, // <-- extended state variable
freeze(){ this.temp = 32 }
})
Imo, this approach works well for when there is a "source of truth" (in this case temp
) that the states can readily be derived from. This is not true of many FSMs where the transition matters quite a bit. This FSM, for example, does not require someone to call a heat()
method twice before making steam. It doesn't tell you if you "can" switch from one state to another.
I think a FSM would be great for CanJS. (I just want to give people some tools in the meantime).
Other thougths
- I'm not sure if you are, but I wouldn't really limit yourself to something
DefineMap
-like. It's fairly straightforward to make things observable so they can work in stache and as a component's VM now. - Have you thought about just making something like https://github.com/jakesgordon/javascript-state-machine observable? This doesn't seem to support extended state variables. How important are those to you? In my experience, they are often important.
- I think functions like
.heat()
,.freeze()
will look better in stache thanto(state, variables)
.on:click="heat()"
compared toon:click="to('heat')
.
The ATM example is how I do this sort of thing on client work and prior to writing my little library. To answer your other thoughts:
-
I am not really sure what you mean by
DefineMap
-like, or rather, what something looks like that is not that. -
I did look up other implementations but I hadn't written an FSM since undergrad, and I had never written one in JavaScript. So I created my library more for fun than anything else.
-
The support for extending state variables only came after thinking about the use case where the Application would switch from an
unauthenticated
state with anull
user
property to anauthenticated
state with a truthyuser
property after making alogin
request to the server. -
I do like the idea of edges being turned into functions. I originally just used strings because I was defining events names as constants and then used those event names as the names of the edges, reducing potential typos. Good idea.
To make something like stated
work as an observable, it needs to add a few symbols (and call ObservationRecorder.add()
). An example can be seen here:
https://github.com/canjs/can-observation/blob/master/test/simple.js#L148
And here:
https://github.com/canjs/can-simple-map/blob/master/can-simple-map.js#L144
The critical symbols are:
- https://canjs.com/doc/can-reflect/observe.onKeyValue.html
- https://canjs.com/doc/can-reflect/observe.offKeyValue.html
Then if someone reads .state
you'll want something like:
get state(){
ObservationRecorder.add(this, "state");
return this._state;
}
And when state changes, you'll want to make sure you dispatch all the right events:
somethingInternalThatSetsState(){
queues.enqueueByQueue(this.handlers.getNode(["value"]), this, [value], function(){
return {};
});
}
Much of this (and more) can be mixed in via https://canjs.com/doc/can-event-queue/map/map.html
That might look like:
CanStated extends Stated {
has(action, updateValue) {
const transitionTo = this.states[this.state][action];
if (!transitionTo) {
throw `'${action}' does not exist as an action of '${this.state}'`;
}
if (typeof transitionTo !== 'string') {
throw `'${transitionTo}' is not a valid state. It must be a string.`
}
if (!this.states[transitionTo]) {
throw `'${transitionTo}' does not exist`;
}
var oldState = this._state;
this._state = transitionTo;
this[canSymbol.for("can.dispatch")]("state", [this._state, oldState]);
if (updateValue) this.value = updateValue;
return this;
}
get state(){
ObservationRecorder.add(this, "state");
}
}
mixinValueBindings(CanStated.prototype);
State machines are usually quite trivial to implement. DefineMap
should front this as with any other state but it certainly should not be a state machine. Well, I cannot see why.
If you need this functionality directly on a DefineMap
then I'd create a derived type to do this.
While state machines are usually trivial to implement, I like the idea of having a consistent way to define them, especially when the simple definition creates a more complex object that is easy to use.
Along those lines, I would be in favor of the edges (.frozen()
or .toFrozen()
) and checks (.isFrozen()
) being methods rather than strings (with the possibility to overload these functions to take extra params for changing related values).
Similarly, I would be in favor of a way to specify what state transitions are valid. Using the main example above, you cannot go from ice to steam (at least at STP), and being able to prevent this and present a useful error message would be nice.
@christopherjbaker I should have been more pedantic in my example. In the example, you can transition from ice to steam because I specified it that way not thinking about the real world. To stop that from happening you would simple remove the "action".
ice: {
warm: 'initial',
values: {
temp: '32F'
}
},
Now if you were in the ice
state and did water.is('boiling')
you would get an error (this proposal was model after a project of mine, and here is the error https://github.com/mjstahl/stated/blob/master/index.js#L76)