Feature request: optionally passing a map of strings to functions in statecharts to be returned to the interpreter.
When describing a statechart in JSON, you normally define actions and activities as strings that are then returned to the caller of the transition() function. It would be convenient to be able to pass the action and activity lookup to the statechart JSON before passing it to the Machine constructor.
Define a statechart using strings for actions and activities:
const STATECHART = {
initial: 'turnedOff',
states: {
turnedOff: {
on: {
FLICK: {
turnedOn: {
actions: ['trackUserFlickedOn', 'groanAboutBrightness']
}
}
}
},
turnedOn: {
activities: ['powerDrain'],
on: {
FLICK: { turnedOff: { actions: ['trackUserFlickedOff'] } }
}
}
}
}
This is completely serializable as JSON and does not lose information for actions and activities, since they're just strings.
For actions and activities, we can define them as optional actions and activities lookups anywhere in a machine:
const actions = {
trackUserFlickedOn: () => console.log('User turned the lights on'),
trackUserFlickedOff: () => console.log('User flicked the lights off')
};
// NOTE: the activity can be any value or object, but for clarity,
// I decided to model it as an object with start() and stop()
const activities = {
powerDrain: {
_startTime: null,
start: function() {
this._startTime = Date.now();
},
stop: function() {
const elapsedTime = (Date.now() - this._startTime) / 1000 / 3600; // hours
const kWh = 0.150 * elapsedTime;
console.log(`Assuming 150W light, user used ${kWh} kWh of power.`);
}
}
};
When creating the machine instance, just augment the statechart description by placing the actions and activities next to the states property:
const machine = new Machine({ ...STATECHART, actions, activities });
The transition() function can now look up the actions and activities recursively from the innermost machine upwards to substitute any strings by their proper implementations:
const state = machine.transition('FLICK');
/*
{
value: 'turnedOn',
activities: { powerDrain: true },
actions: [
// substitute "trackUserFlickedOn" by looked up function
actions.trackUserFlickedOn,
// because the string wasn't found in the lookup, don't substitute the string
'groanAboutBrightness',
{
type: 'xstate.start',
// here's the activity substituted for the object above
activity: activities.powerDrain
}
]
}
*/
The groanAboutBrightness action fails to be found in the current machine, but could resolve if this statechart were part of a bigger hierarchical machine that did specify that particular action name. The lookup always resolves to the innermost action lookup.
This seems to be something that could be done in an interpreter, i.e. a stateful wrapper around xstate. In there you could even get it it to _call_ the functions too, with all the "extended state" that you want too.
I want to normalize the actions array in 4.0. Does this look good as a universal object structure?
actions: [
{
type: 'trackUsersFlickedOn',
exec: actions.trackUsersFlickedOn, // implementation from lookup
// ... other props
},
{
type: 'groanAboutBrightness' // no exec implementation
}
];
And each action will implement .toString() which returns the action.type, so it'll be (somewhat) backwards compatible.
That does look like it would do the trick, although I think @mogsie's suggestion of a wrapper is also an option. Just seemed like a convenience at the time.
For machine options, actions is already implemented, but activities might be:
const machine = Machine({...}, {
actions: { ... },
activities: {
powerDrain: {
start: (context, event) => { /* ... */ },
stop: (context, event) => { /* ... */ }
}
}
});
That sounds like it would fit the bill. :) Exposing an interface for the activity would also help with anyone using @ts-check in JS.
Exposing an interface for the activity would also help
Doing that as we speak 馃槃
I was also thinking of an alternative (optional) config, since it might be more idiomatic in certain contexts, of the type:
activities: {
powerDrain: function startPowerDrain() {
// start the activity
const powerDrain = someService.powerDrain.initialize();
// return function that stops the activity
return powerDrain.dispose;
}
}
My 2 pence: this might be a case of putting too many affordances in place, and it can be confusing for a user to determine what the preferred/best/idiomatic way to implement something is. To me, the interface approach of handing an instance is a superset of the constructor pattern in the last comment, so it may be worth considering whether adding an extra affordance would be confusing for people.
Otherwise, I love that you can provide mappings like this, it makes it much easier to simplify the interpreter part of the equation.
@davidkpiano, so string actions are gonna be deprecated at all in favor to action objects?
string actions are gonna be deprecated at all in favor to action objects?
No they will not be. String actions are still fine.
Last minute change: I think I'll want to simplify the activity config to be just a function (that might or might not return a "dispose" function).
The reason being, if we did the start/stop config, we have a problem when invoking individual services:
const myActivity = {
start: () => {
const service = new Service();
service.initialize();
},
stop: () => {
// err... how do we get the referenced service?
// service.dispose();
}
});
Instead, if it were just a function, it could be:
const myActivity = () => {
const service = new Service();
service.start();
return service.dispose; // this is optional, but recommended of course
};
Thoughts?
Having one function looks good to me.
This (the one-function activity syntax) is now in master and will be released in 4.0.
Most helpful comment
Last minute change: I think I'll want to simplify the activity config to be just a function (that might or might not return a "dispose" function).
The reason being, if we did the
start/stopconfig, we have a problem when invoking individual services:Instead, if it were just a function, it could be:
Thoughts?