@avaragado/xstateful v0.5.0
@avaragado/xstateful
A wrapper for
xstatethat stores state, handles transitions, emits events for state changes and actions/activities, and includes an optional reducer framework for updating state and invoking side-effects
Features
An XStateful instance:
- Stores current machine state for an
xstateinstance - Stores current extended state
- Includes a
transitionmethod for updating machine state based on events - Includes a
setExtStatemethod for updating extended state - Emits
changeevents when machine state or extended state change - Emits
actionevents when a new machine state includes actions or activities
The createStatefulMachine export adds a reducer framework for handling actions/activities:
- Supports Redux-like reducer functions or a map of them indexed by action/activity type
- Uses ReasonReact-like reducer return values to declare extended state updates, or side-effects, or both
- Supports delayed events ("send event after N ms") and periodic events ("send event every N ms")
Why?
Statecharts are a great way to model user interfaces and user interaction. We use them in projects implicitly and poorly without even noticing: often as multiple independent booleans, relying on randomly distributed business logic to constrain them to reality. This makes behaviour hard to model, visualise and test. xstate lets us separate this behaviour from the nuts and bolts of the user interface itself.
xstate is designed to manage the statechart, offering a pure function per machine that returns the next machine state given the current machine state and an event. It doesn't remember that new machine state, or the additional non-machine state ("extended state") required in real-world usage. Neither does it trigger any actions or activities your statechart defines: it tells you what to do, and leaves the rest up to you. xstateful adds these features.
Other packages offer these features too, but closely coupled to specific libraries such as Redux or React. If your app needs both to connect xstate machines to Redux, and to use simpler machines in a React UI that don't need to use Redux, you might end up using multiple different approaches simultaneously: more cognitive load and bigger bundles. xstateful simply wraps xstate in an instance of an XStateful class that adds extended state and event emitting, and provides a clean way to update extended state and perform side-effects based on machine actions and activities.
Terminology
Most terminology is as used in xstate, often qualified to try to reduce ambiguity. These are terms like action, activity, machine, (machine) event, (machine) state, extended state and transition.
The term reducer is mildly abused, but the sense is very similar to Redux and influenced by ReasonReact.
Installation
$ yarn add xstate @avaragado/xstateful
$ # or
$ npm install xstate @avaragado/xstatefulGetting started
In summary:
- Design and build an
xstatemachine, including any actions, activities and extended state requirements. - Create an
xstatefulreducer to handle the actions and activities, updating extended state and defining side-effects. - Call the
xstatefulfunctioncreateStatefulMachine, passing thexstatemachine, your initial extended state, and the reducer. This function returns anXStatefulinstance and ties its emitted actions/activities to your reducer. - Add a
changeevent listener to theXStatefulinstance to receive updates to machine state and extended state. - Call the
XStatefulinstance'sinitmethod to start the machine. - Send events to the machine using the
XStatefulinstance'stransitionmethod. - Update extended state if necessary using the
XStatefulinstance'ssetExtStatemethod.
(You can use xstateful without the reducer extras: in this case, add action event listeners to process emitted actions and activities however you'd like.)
We'll introduce xstateful's features using simple, working examples (we assume you're already familiar with xstate).
Example 1: up-down
This machine has no extended state, no actions, and no reducer.
import { Machine } from 'xstate';
import { createStatefulMachine } from '@avaragado/xstateful';
const machine = Machine({
key: 'up-down',
initial: 'down',
states: {
up: {
on: {
SWITCH: 'down',
},
},
down: {
on: {
SWITCH: 'up',
},
},
},
});
// this function takes an object as argument: the property 'machine' is required
// and must be an xstate StateNode instance
const xsf = createStatefulMachine({ machine });
// the machine is inert until you call init.
// (call init later to reset to initial state and extstate).
xsf.init();
// xsf.state = the current machine state, as determined by xstate
console.log(xsf.state.value);
// down
// call the transition method to send an xstate event to the machine
xsf.transition('SWITCH');
console.log(xsf.state.value);
// up
xsf.transition('SWITCH');
console.log(xsf.state.value);
// downxstatefulsupports any statechart thatxstatesupports: see the xstate documentation.- Pass
xstateStateNodeinstances (returned by theMachinefunction), not configuration objects, to thexstatefulfunctioncreateStatefulMachine. - Call the
XStatefulinstance'sinitmethod to start the machine. Its machine state isnullbefore you callinit. - Call the
initmethod later to completely reset the machine. - Find the current machine state, according to
xstate, in theXStatefulinstance'sstatefield. - Call an
XStatefulinstance'stransitionmethod to send an event to thexstatemachine and store the resulting state. (The instance calls thexstatetransitionmethod under the hood.) - You can call
transitionwith a string event name likeSWITCH, or a more complex event object such as{ type: 'DIGIT', char: '6' }.
Example 2: up-down-guard
Let's add some extended state, and use that in some transition guards.
import { Machine } from 'xstate';
import { createStatefulMachine } from '@avaragado/xstateful';
const machine = Machine({
key: 'up-down-guard',
initial: 'down',
states: {
up: {
on: {
SWITCH: {
down: {
cond: extstate => extstate.canSwitch,
},
},
},
},
down: {
on: {
SWITCH: {
up: {
cond: extstate => extstate.canSwitch,
},
},
},
},
},
});
// an arbitrary object
const extstate = {
canSwitch: true,
foo: 123,
};
// pass the initial extended state as 'exstate' in the argument object
const xsf = createStatefulMachine({ machine, extstate });
xsf.init();
console.log(xsf.state.value);
// down
xsf.transition('SWITCH');
console.log(xsf.state.value);
// up
// xsf.extstate = the current extended state
// setExtState specifies a sparse update (any other properties untouched)
xsf.setExtState({ canSwitch: !xsf.extstate.canSwitch });
// alternative functional form, that takes the current extended state
// and returns a sparse update:
// xsf.setExtState(extstate => ({ canSwitch: !extstate.canSwitch }));
xsf.transition('SWITCH');
console.log(xsf.state.value);
// up- Extended state is an object: it can contain anything.
- Find the current extended state in the
XStatefulinstance'sextstatefield. - An
XStatefulinstance manages extended state and has a methodsetExtStateto modify it. - Pass a sparse update object, or a function from current extended state to a sparse update object, as the argument to
setExtState. - A falsy update means the extended state isn't changed.
- An
XStatefulinstance automatically passes extended state to any guards in your statechart.
Example 3: log-changes
We can subscribe to changes to machine state and extended state by adding handlers for the change event.
XStateful extends tiny-emitter under the hood.
import { Machine } from 'xstate';
import { createStatefulMachine } from '@avaragado/xstateful';
const machine = Machine({
key: 'log-changes',
initial: 'down',
states: {
up: {
on: {
SWITCH: {
down: {
cond: extstate => extstate.canSwitch,
},
},
},
},
down: {
on: {
SWITCH: {
up: {
cond: extstate => extstate.canSwitch,
},
},
},
},
},
});
const extstate = {
canSwitch: true,
foo: 123,
};
const xsf = createStatefulMachine({ machine, extstate });
const log = ({ state, extstate: xs }) => {
console.log(
`state: ${state.value}, extstate: ${JSON.stringify(xs, null, 4)}`,
);
};
// the event handler is called whenever state or extstate changes
// (you can compare object references: these objects are updated immutably).
xsf.on('change', log);
xsf.init(); // changes state (to initial state) => log
xsf.transition('SWITCH'); // changes state => log
xsf.setExtState({ canSwitch: !xsf.extstate.canSwitch }); // changes extstate => log
xsf.transition('SWITCH'); // no change in state or extstate => no log
// remove event handler.
// you can also use xsf.once(...) for one-time handlers.
xsf.off('change', log);
// output:
// state: down, extstate: {
// "canSwitch": true,
// "foo": 123
// }
// state: up, extstate: {
// "canSwitch": true,
// "foo": 123
// }
// state: up, extstate: {
// "canSwitch": false,
// "foo": 123
// }- An
XStatefulinstance has thetiny-emitterinstance methodson,once, andoffto manage event listeners. - The
XStatefulinstance emits achangeevent whenever the machine state or extended state changes. - Machine state and extended state are immutable: there'll be a new object reference when they change.
Example 4: actions-activities
Let's add actions and activities to our statechart (and remove the guards).
import { Machine } from 'xstate';
import { createStatefulMachine } from '@avaragado/xstateful';
const machine = Machine({
key: 'actions-activities',
initial: 'down',
states: {
up: {
activities: ['humming'],
on: {
SWITCH: {
down: {
actions: ['buzz', 'click'],
},
},
},
},
down: {
onEntry: ['ping'],
onExit: ['pong'],
on: {
SWITCH: {
up: {
actions: ['honk'],
},
},
},
},
},
});
const xsf = createStatefulMachine({ machine });
let ix = 1;
const logState = ({ state }) => {
console.log(`${ix} state: ${state.value}\n`);
ix += 1;
};
const logAction = ({ action }) => {
console.log(`${ix} action: ${JSON.stringify(action, null, 4)}\n`);
ix += 1;
};
const logActions = phase => actions => {
console.log(
`${ix} ${phase} actions: ${JSON.stringify(actions, null, 4)}\n`,
);
ix += 1;
};
xsf.on('change', logState);
// the 'action' event occurs whenever a transition results in actions or
// activity start/stop. you see one event per action, with separate events for
// activity start and activity stop.
xsf.on('action', logAction);
// the 'before-actions' and 'after-actions' events occur before and after
// all 'action' events for a transition.
xsf.on('before-actions', logActions('before'));
xsf.on('after-actions', logActions('after'));
// numbers in comments relate to output shown below
xsf.init(); // 1, 2, 3, 4
xsf.transition('SWITCH'); // 5, 6, 7, 8, 9, 10
xsf.transition('SWITCH'); // 11, 12, 13, 14, 15, 16, 17
xsf.off('change', logState);
xsf.off('action', logAction);
// output:
// 1 before actions: [
// {
// "type": "ping"
// }
// ]
//
// 2 action: {
// "type": "ping"
// }
//
// 3 after actions: [
// {
// "type": "ping"
// }
// ]
//
// 4 state: down
//
// 5 before actions: [
// {
// "type": "pong"
// },
// {
// "type": "honk"
// },
// {
// "type": "xstate.start",
// "activity": "humming",
// "data": {
// "type": "humming"
// }
// }
// ]
//
// 6 action: {
// "type": "pong"
// }
//
// 7 action: {
// "type": "honk"
// }
//
// 8 action: {
// "type": "xstate.start",
// "activity": "humming",
// "data": {
// "type": "humming"
// }
// }
//
// 9 after actions: [
// {
// "type": "pong"
// },
// {
// "type": "honk"
// },
// {
// "type": "xstate.start",
// "activity": "humming",
// "data": {
// "type": "humming"
// }
// }
// ]
//
// 10 state: up
//
// 11 before actions: [
// {
// "type": "xstate.stop",
// "activity": "humming",
// "data": {
// "type": "humming"
// }
// },
// {
// "type": "buzz"
// },
// {
// "type": "click"
// },
// {
// "type": "ping"
// }
// ]
//
// 12 action: {
// "type": "xstate.stop",
// "activity": "humming",
// "data": {
// "type": "humming"
// }
// }
//
// 13 action: {
// "type": "buzz"
// }
//
// 14 action: {
// "type": "click"
// }
//
// 15 action: {
// "type": "ping"
// }
//
// 16 after actions: [
// {
// "type": "xstate.stop",
// "activity": "humming",
// "data": {
// "type": "humming"
// }
// },
// {
// "type": "buzz"
// },
// {
// "type": "click"
// },
// {
// "type": "ping"
// }
// ]
//
// 17 state: down- An
XStatefulinstance firesbefore-actions,actionandafter-actionsevents while processing the actions and activities thatxstateemits for a state transition. - Synchronously, an
XStatefulinstance first firesbefore-actions, then oneactionevent per action or activity start/stop, in order, then finallyafter-actions. - All actions and activities are described in object form, with a
typeproperty matching the string name of the action. xstateemits separate "actions" for activity start and activity stop. These action objects have:- A
typeproperty with a magic value indicating activity start or stop - An
activityproperty with a string value identifying the activity - A
dataproperty containing the original activity in object form (sodata.typewill exist, and be the same asactivity)
- A
xstatefulexports an objectACTION_TYPEwith string propertiesACTIVITY_STARTandACTIVITY_STOPto test for magic activitytypevalues. (I should probably export functions instead.)xstatefulcallsbefore-actionsandafter-actionsevent handlers with the array of action objects it is about to process or has just processed.xstatefulcallsactionevent handlers with an object containing these properties:state— The machine state at the time of the action (the target state of any transition).extstate— The extended state at the time of the action (possibly updated by previous actions).event— The event triggering the action, or null if none. If not null, this is always an object with atypeproperty.action— The action details. This is always an object with atypeproperty.
Example 5: actions-extstate
Let's see how actions can modify extended state.
import { Machine } from 'xstate';
import { createStatefulMachine } from '@avaragado/xstateful';
const machine = Machine({
key: 'actions-extstate',
initial: 'down',
states: {
up: {
on: {
SWITCH: {
down: {
// actions can be objects: 'type' property is required
actions: [{ type: 'inc', key: 'down' }, 'incTotal'],
},
},
},
},
down: {
on: {
SWITCH: {
up: {
actions: [{ type: 'inc', key: 'up' }, 'incTotal'],
},
},
},
},
},
});
const extstate = {
up: 0,
down: 0,
switches: 0,
};
const xsf = createStatefulMachine({ machine, extstate });
const logState = ({ state, extstate: xs }) => {
console.log(
`state: ${state.value} -- switches: ${xs.switches} (${xs.up} up, ${
xs.down
} down)`,
);
};
// a SWITCH event emits two actions, each of which modifies extstate, but the
// 'change' handler is called once, after all actions, for each SWITCH.
const handleAction = ({ action }) => {
console.log(action.type);
switch (action.type) {
case 'inc': {
xsf.setExtState(xs => ({ [action.key]: xs[action.key] + 1 }));
break;
}
case 'incTotal': {
xsf.setExtState(xs => ({ switches: xs.switches + 1 }));
break;
}
default: {
break;
}
}
};
xsf.on('change', logState);
xsf.on('action', handleAction, xsf);
xsf.init();
xsf.transition('SWITCH');
xsf.transition('SWITCH');
xsf.transition('SWITCH');
xsf.transition('SWITCH');
xsf.off('change', logState);
xsf.off('action', handleAction);
// output:
// state: down -- switches: 0 (0 up, 0 down)
// inc
// incTotal
// state: up -- switches: 1 (1 up, 0 down)
// inc
// incTotal
// state: down -- switches: 2 (1 up, 1 down)
// inc
// incTotal
// state: up -- switches: 3 (2 up, 1 down)
// inc
// incTotal
// state: down -- switches: 4 (2 up, 2 down)- Machine configurations can specify actions as objects to pass arbitrary static data to the handler. The
XStatefulinstance passes action objects unchanged to the handler. - An
actionhandler can call theXStatefulinstance'ssetExtStatemethod synchronously to modify extended state. - Any subsequent
actionhandlers for the same event see the updated extended state. - The
XStatefulinstance calls anychangehandler once, after allactionhandlers, if extended state has changed.
Example 6: actions-reducer
Now let's start using xstateful's reducer functionality.
You may be familiar with reducers from Redux and similar state management libraries. In Redux, a reducer is a pure function of both the current state and a Redux action, and it returns the next state. You compose Redux reducers to form a single root reducer that transforms your store's state immutably.
xstateful uses a similar but slightly different approach, more like ReasonReact. The purpose of an xstateful reducer is to specify, given a statechart event, an emitted action, the current machine state, and the current extended state, both how the extended state should change (if at all) and any side effects to perform (if any). You can write reducers in various styles, ranging from "almost Redux" to "hardly any boilerplate".
Aside: Naming things is hard The closest equivalent to a Redux action ("something happened in userland") is an xstate event, not an action. The xstate machine emits actions and starts/stops activities, according to its configuration, and your reducers respond primarily to those actions and activities. Redux state is most like xstateful's extended state.
Let's modify example 5 to use a reducer.
import { Machine } from 'xstate';
// extra import
import { createStatefulMachine, Reducer } from '@avaragado/xstateful';
const machine = Machine({
key: 'actions-reducer',
initial: 'down',
states: {
up: {
on: {
SWITCH: {
down: {
actions: [{ type: 'inc', key: 'down' }, 'incTotal'],
},
},
},
},
down: {
on: {
SWITCH: {
up: {
actions: [{ type: 'inc', key: 'up' }, 'incTotal'],
},
},
},
},
},
});
const extstate = {
up: 0,
down: 0,
switches: 0,
};
// xstateful passes a reducer the same as it passes an action handler.
// reducers must return the result of calling a Reducer static method.
const reducer = ({ action, extstate: xs }) => {
switch (action.type) {
case 'inc': {
// sparse update
return Reducer.update({ [action.key]: xs[action.key] + 1 });
}
case 'incTotal': {
return Reducer.update({ switches: xs.switches + 1 });
}
default: {
// explicitly say "nothing changed"
return Reducer.noUpdate();
}
}
};
// pass your reducer as the "reducer" key in the argument
const xsf = createStatefulMachine({ machine, extstate, reducer });
const logState = ({ state, extstate: xs }) => {
console.log(
`state: ${state.value} -- switches: ${xs.switches} (${xs.up} up, ${
xs.down
} down)`,
);
};
xsf.on('change', logState);
xsf.init();
xsf.transition('SWITCH');
xsf.transition('SWITCH');
xsf.transition('SWITCH');
xsf.transition('SWITCH');
xsf.off('change', logState);
// output:
// state: down -- switches: 0 (0 up, 0 down)
// state: up -- switches: 1 (1 up, 0 down)
// state: down -- switches: 2 (1 up, 1 down)
// state: up -- switches: 3 (2 up, 1 down)
// state: down -- switches: 4 (2 up, 2 down)- Import
Reducerfrom thexstatefulpackage to start using reducers. - A reducer can be a function that takes
{ state, extstate, action, event }(the same as an action handler) and returns the result of calling a static method on theReducerobject. - Include your reducer as the
reducerkey in the argument tocreateStatefulMachine. - Return
Reducer.noUpdate()to indicate that extended state does not change. - Return
Reducer.update({ ... })to declare a sparse update to extended state. - (Examples below describe how you declare side-effects, and how to reduce boilerplate)
Example 7: actions-reducer-map
Example 6 shows a "traditional" Redux-like reducer: a single function that switches on action type. An alternative that avoids some of the switch/case boilerplate is a reducer map. Here's the same code, replacing the reducer function with a reducer map.
import { Machine } from 'xstate';
import { createStatefulMachine, Reducer } from '@avaragado/xstateful';
const machine = Machine({
key: 'actions-reducer',
initial: 'down',
states: {
up: {
on: {
SWITCH: {
down: {
actions: [{ type: 'inc', key: 'down' }, 'incTotal'],
},
},
},
},
down: {
on: {
SWITCH: {
up: {
actions: [{ type: 'inc', key: 'up' }, 'incTotal'],
},
},
},
},
},
});
const extstate = {
up: 0,
down: 0,
switches: 0,
};
// reducer maps let you reduce boilerplate.
// keys are action types, or activity types suffixed with ':start' or ':stop'.
// values are calls to Reducer methods, or functions returning calls to Reducer methods.
const reducer = Reducer.map({
inc: Reducer.update(({ action, extstate: xs }) => ({
[action.key]: xs[action.key] + 1,
})),
incTotal: Reducer.update(({ extstate: xs }) => ({
switches: xs.switches + 1,
})),
// if the statechart had an activity type 'whistle':
// 'whistle:start': ...
// 'whistle:stop': ...
});
const xsf = createStatefulMachine({ machine, extstate, reducer });
const logState = ({ state, extstate: xs }) => {
console.log(
`state: ${state.value} -- switches: ${xs.switches} (${xs.up} up, ${
xs.down
} down)`,
);
};
xsf.on('change', logState);
xsf.init();
xsf.transition('SWITCH');
xsf.transition('SWITCH');
xsf.transition('SWITCH');
xsf.transition('SWITCH');
xsf.off('change', logState);
// output:
// state: down -- switches: 0 (0 up, 0 down)
// state: up -- switches: 1 (1 up, 0 down)
// state: down -- switches: 2 (1 up, 1 down)
// state: up -- switches: 3 (2 up, 1 down)
// state: down -- switches: 4 (2 up, 2 down)- Create a reducer by wrapping a reducer map object in a call to
Reducer.map. - In a reducer map object, each key is either an action type, such as
inc, or an activity type with a suffix:startor:stop, such aswhistle:startorwhistle:stop. - The values in a reducer map object are reducers themselves: either calls to
Reducerstatic methods, or functions that return them. (You can't nest reducer maps.)
Example 8: effects
In ReasonReact, reducers can declare side-effects as well as updates to state. xstateful uses a similar approach. Code is code, so xstateful can't stop a reducer doing whatever it wants whenever it wants. But using a standard way to declare side-effects brings consistency and helps to make behaviour more predictable.
import { Machine } from 'xstate';
import { createStatefulMachine, Reducer } from '@avaragado/xstateful';
// let's go async!
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const dtStart = new Date();
const stamp = () => Math.round((new Date() - dtStart) / 100) / 10;
const log = msg => console.log(`${stamp()}s ${msg}`);
const machine = Machine({
key: 'effects',
initial: 'powerOff',
states: {
powerOff: {
on: {
SWITCH: 'powerOn',
},
},
powerOn: {
activities: ['log'],
on: {
SWITCH: 'powerOff',
},
initial: 'wheeze',
states: {
wheeze: {
// see comment before delayTick in the reducer about ordering
onEntry: ['delayTick', 'inc'],
on: {
TICK: 'groan',
},
},
groan: {
onEntry: 'delayTick',
on: {
TICK: 'wheeze',
},
},
},
},
},
});
const extstate = {
count: 0,
};
const reducer = Reducer.map({
// use effects for things that aren't synchronous updates to extended state,
// such as logging, fetches, or delayed events
'log:start': Reducer.effect(() => {
log('POWER ON!');
}),
'log:stop': Reducer.effect(() => {
log('POWER OFF!');
}),
inc: Reducer.update(({ extstate: xs }) => ({ count: xs.count + 1 })),
// effects run after updates: even though delayTick appears before inc in
// the onEntry value for powerOn.wheeze, the inc has already occurred when
// the effect runs.
delayTick: Reducer.effect(xsf => {
// careful! this *always* fires, whichever state the machine is currently in
setTimeout(() => xsf.transition('TICK'), 1000);
}),
});
const xsf = createStatefulMachine({ machine, extstate, reducer });
const logState = ({ state, extstate: xs }) => {
log(`state: ${state.toString()}, count: ${xs.count}`);
};
const run = async () => {
xsf.on('change', logState);
xsf.init();
xsf.transition('SWITCH');
await delay(5000);
xsf.transition('SWITCH');
xsf.off('change', logState);
};
run();
// output:
// 0s state: powerOff, count: 0
// 0s POWER ON!
// 0s state: powerOn.wheeze, count: 1
// 1s state: powerOn.groan, count: 1
// 2s state: powerOn.wheeze, count: 2
// 3s state: powerOn.groan, count: 2
// 4s state: powerOn.wheeze, count: 3
// 5s POWER OFF!
// 5s state: powerOff, count: 3- Use side-effects for everything that isn't a synchronous update to extended state. Examples include logging, fetches and delayed events or updates.
- Return
Reducer.effect(effect)from a reducer to declare a side-effect. - Return
Reducer.updateWithEffect(update, effect)from a reducer to declare both an update to extended state and a side-effect. xstatefulapplies all extended state updates, in action order, before invoking all side-effects, in action order. Updates and side-effects are always applied/invoked synchronously.- For example, if a machine emits actions
one,two,threefor a single transition, then any updates fromoneapply first, then updates fromtwo, and finally updates fromthree. Then any declared side-effect functions are invoked: effects fromone, thentwo, thenthree. - The effects themselves can behave asynchronously.
xstatefulignores any return value from an effect function, so it doesn't wait for any returned Promise to resolve.
- For example, if a machine emits actions
- For every side-effect, the
effectfunction is called with two arguments: theXStatefulinstance, and{ state, extstate, action, event }(the same as an action handler). This function can do whatever it likes.- Use the
XStatefulinstance to access current values for machine state and extended state, and to trigger any async transitions or async updates. (Because updates precede effects, the extended state value when an effect runs will incorporate the updates from all actions.) - Use the second argument to the
effectfunction to access custom action data or event data needed for the side-effect. (Thestateandextstateproperties of the second argument are the values as they were during the update pass through the actions. This data might occasionally be useful, but use with care.)
- Use the
- Side-effect functions can call the
XStatefulinstance'stransitionandsetExtStatemethods, synchronously or asynchronously. It's cleaner to useReducer.updatethan to callsetExtStatesynchronously from an effect. - No matter how many synchronous updates to extended state occur while
xstatefulprocesses actions, there's only onechangeevent with the final values.
Example 9: tick-tock
Some statecharts need automatic timed events: either a periodic event where the same event fires regularly, or a delayed event where an event is fired once after a certain period of time. In example 8, we used a delayTick action that fired TICK after a second using the standard JavaScript setTimeout function. This works, but there's a potential problem: using setTimeout, the event fires whether or not the machine is still in the same state. What if the new state responds to that event, and we don't want it to?
xstateful includes helpers for these scenarios, supporting periodic events and delayed events that fire only when the machine is in the correct state. They build on xstate's support for activities, emitting start and stop actions when entering and leaving the state.
import { Machine } from 'xstate';
import { createStatefulMachine, Reducer } from '@avaragado/xstateful';
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const dtStart = new Date();
const stamp = () => Math.round((new Date() - dtStart) / 100) / 10;
const log = msg => console.log(`${stamp()}s ${msg}`);
// use an interval activity to send an event periodically
// while in the same state.
const ticker = Reducer.util.intervalActivity({
activity: 'tickEvery1s',
ms: 1000,
event: 'TICK',
});
// use a timeout activity to send a delayed event once
// while in the same state.
const autoOff = Reducer.util.timeoutActivity({
activity: 'switchAfter5s',
ms: 5000,
event: 'SWITCH',
});
const machine = Machine({
key: 'tick-tock',
initial: 'powerOff',
states: {
powerOff: {
on: {
SWITCH: 'powerOn',
},
},
powerOn: {
// the activity property holds the name of the activity
activities: ['log', ticker.activity, autoOff.activity],
on: {
SWITCH: 'powerOff',
},
initial: 'wheeze',
states: {
wheeze: {
activities: ['tick'],
on: {
TICK: 'groan',
},
},
groan: {
activities: ['tock'],
on: {
TICK: 'wheeze',
},
},
},
},
},
});
const reducer = Reducer.map({
'log:start': Reducer.effect(() => {
log('POWER ON!');
}),
'log:stop': Reducer.effect(() => {
log('POWER OFF!');
}),
// spread the map property into the reducer map
...ticker.map,
...autoOff.map,
});
const xsf = createStatefulMachine({ machine, reducer });
const logChange = ({ state }) => {
const tick = state.activities.tick ? 'TICK <' : ' ';
const tock = state.activities.tock ? '> TOCK' : ' ';
log(`${tick}----${tock}`);
};
const run = async () => {
xsf.on('change', logChange);
log('start');
xsf.init();
// powerOff -> powerOn.wheeze
xsf.transition('SWITCH');
await delay(7000);
// as we wait, ticker fires TICK every second
// but after 5 seconds, autoOff fires SWITCH.
// manually restart
xsf.transition('SWITCH');
await delay(4000);
// ticker and autoOff do their stuff again.
// but before autoOff has time to fire, we manually power down.
xsf.transition('SWITCH');
// wait longer to prove that autoOff won't fire
// (because it exited the state)
await delay(5000);
log('stop');
xsf.off('change', logChange);
};
run();
// output:
// 0s start
// 0s ----
// 0s POWER ON!
// 0s TICK <----
// 1s ----> TOCK
// 2s TICK <----
// 3s ----> TOCK
// 4s TICK <----
// 5s POWER OFF!
// 5s ----
// 7s POWER ON!
// 7s TICK <----
// 8s ----> TOCK
// 9s TICK <----
// 10s ----> TOCK
// 11s POWER OFF!
// 11s ----
// 16s stop- To use a delayed event or a periodic event:
- Create an activity/map pair by calling
Reducer.util.timeoutActivityorReducer.util.intervalActivitywith the name, the delay/period (in milliseconds) and the event (string or object) you want to fire. These methods return an object. - Use the object's
activityproperty in the machine configuration, as an activity in the appropriate state. This is a string value (it's the name specified in the first step). - Spread the object's
mapproperty in the reducer map object. This addsstartandstopreducers for the activity, to correctly create and cancel the events when entering and leaving the state.
- Create an activity/map pair by calling
- Each activity has an internal handle for the timer. In the machine configuration, ensure the same activity never overlaps, or the handles will become overwritten.
Reference
Module exports
import {
createStatefulMachine,
XStateful,
Reducer,
ACTION_TYPE,
} from '@avaragado/xstateful';createStatefulMachine function
createStatefulMachine({ machine, reducer?, extstate? }) => XStateful
Returns an XStateful instance for the machine, using the reducer for processing all actions the machine emits, and initialising with the extended state extstate.
machineis anxstatemachine, as returned by theMachinefunction. (xstatetype:StateNode)reduceris a reducer function or value: see theReducersection below. Defaults to no reducer.extstateis an arbitrary object. Defaults to{}.
You don't need to call this function to use xstateful: you can create an instance of XStateful yourself and write your own event handlers for xstate actions. Using createStatefulMachine brings you the benefits of xstateful's built-in handling for actions, activities and extended state.
XStateful class
Instances of this class maintain the current machine state, plus the current extended state used for guards and other arbitrary machine-related data. This class inherits from tiny-emitter to provide event listener functionality (where "event" in this context is not the same as a state machine event).
Only the instance methods described here should be considered public.
new XStateful({ machine, extstate })
Creates an XStateful instance for the machine machine, with initial extended state extstate.
machineis anxstatemachine, as returned by theMachinefunction. (xstatetype:StateNode)extstateis an arbitrary object. Specify{}if not using extended state.
xsf.state
Instance variable holding the current machine state, as described by xstate.
xsf.extstate
Instance variable holding the current extended state.
xsf.on()
Inherits from tiny-emitter.
xsf.once()
Inherits from tiny-emitter.
xsf.off()
Inherits from tiny-emitter.
xsf.init()
Places the machine in its initial state, and initialises extended state.
You must call init before calling the transition method. You may call init at any time to reset the machine state and extended state.
May fire before-actions, action and after-actions events (because the initial state may specify onEntry actions). Always fires a change event, after any action-related events (to notify the new machine and extended states).
xsf.setExtState(updater)
Modifies the extended state immutably. updater is:
- either a sparse object, which is merged immutably with the existing extended state
- or a function that's passed the current extended state, and returns a sparse object
If the sparse object supplied or returned is falsy, extended state does not change.
If extended state changes, the instance fires a change event.
xsf.transition(event)
Calls the underlying xstate machine's transition method, passing the instance's current state and extended state, and the event, and stores the resulting state.
event is either a string event type (such as SWITCH), or an object with a string type property (such as { type: 'ALPHA', char: 'x' }. Use the object form to send arbitrary data with the event. (This value is an xstate Event type.)
If the new state declares any actions or activities, the instance fires the appropriate before-actions, action and after-actions events. (If you're using createStateMachine, these are handled for you. If not, add listeners for these yourself to handle them.)
Reducer class
The Reducer class contains static methods to help you build a reducer to supply to createStateMachine. The reducer is used for each action the statechart emits during a transition. You don't use Reducer if you make XStateful instances yourself.
Some descriptions mention a ReducerArg type. This is an object with these properties:
state: a machine state valueextstate: an extended state valueevent: an event objectaction: an action object
If the extended state has changed after the reducer has processed all actions the machine emits for a transition, then an XStateful instance fires a change event.
The reducer value you pass to createStateMachine is:
- either the result of calling
Reducer.map, - or the result of calling one of the
Reducerstatic methodsnoUpdate,update,effectandupdateWithEffect, - or a function that returns one of those four static methods. The function is passed a
ReducerArgvalue.
Reducer.noUpdate()
Declares a reducer return value that makes no changes to extended state and has no side-effects.
Reducer.update(updater)
Declares a reducer return value that updates extended state and has no side-effects. updater is:
- either a sparse object, which is merged immutably with the existing extended state
- or a function that's passed a
ReducerArgvalue and returns a sparse object
If the sparse object supplied or returned is falsy, extended state does not change.
For an ordered list of actions emitted by a transition, xstateful applies all updates, in action order, before invoking all effects declared by those actions.
Reducer.effect(effect)
Declares a reducer return value that has a side-effect, and does not update extended state.
effect is a function (XStateful, ReducerArg) => void | Promise<void>.
The effect function can be synchronous or asynchronous, and can call methods on the XStateful instance passed to it. The effect function's return value is ignored.
The ReducerArg value represents the environment as it was during the update phase for this action: its extstate property incorporates all updates made by earlier actions in the list, and none by later actions.
For an ordered list of actions emitted by a transition, xstateful invokes all effects, in action order, after all updates by those actions.
Reducer.updateWithEffect(updater, effect)
Declares a reducer return value that both updates extended state and has side-effects. The updater and effect arguments are as for the update and effect static methods.
Reducer.map(map)
Returns a reducer that composes smaller, action-specific reducers. Helps you minimise the boilerplate often involved with reducers by automatically mapping the action type to an appropriate action-specfic reducer.
map is an object:
- Each key is either an action type string, or an activity type string with the suffix
:start: or:stop. The:startkey applies when the machine enters a state with this activity, and the:stopkey applies when the machine leaves that state. - Each value is either the result of calling one of the
Reducerstatic methods for declaring reducer results (noUpdate,update,effectorupdateWithEffect), or a function that returns such a result. That function is passed aReducerArgvalue. You can't nest reducer maps.
Reducer.util.timeoutActivity({ activity, ms, event })
Returns a helper object to let you declare delayed events easily. Use this method if you want to send a single delayed event to the machine, at a fixed period of time after entering a state.
activityis a string. This corresponds to an activity name, so make sure all your activity names are distinct.msis a period of time, in milliseconds.eventis an event type string, or an object with atypestring (and other arbitrary data).
The object returned by this method has properties activity and map.
- In your statechart, add the
activityvalue to theactivitiesarray for a state. This ensures the machine starts and stops the activity when entering and leaving the state. - In your reducer map, spread the
mapvalue. This ensures the delay timer is created and cancelled.
Don't overlap timeout activities: don't specify an activity in a state if the same activity is already specified in an ancestor state.
Reducer.util.intervalActivity({ activity, ms, event })
Returns a helper object to let you declare periodic events easily. Use this method if you want to send an event to the machine repeatedly, at intervals of a fixed period of time, while in a certain machine state.
activityis a string. This corresponds to an activity name, so make sure all your activity names are distinct.msis a period of time, in milliseconds.eventis an event type string, or an object with atypestring (and other arbitrary data).
The object returned by this method has properties activity and map.
- In your statechart, add the
activityvalue to theactivitiesarray for a state. This ensures the machine starts and stops the activity when entering and leaving the state. - In your reducer map, spread the
mapvalue. This ensures the interval timer is created and cancelled.
Don't overlap interval activities: don't specify an activity in a state if the same activity is already specified in an ancestor state.
ACTION_TYPE object
This object contains two properties, for the magic action type strings used by xstate to represent the starting and stopping of an activity.
If you need to check for these magic action types, compare against ACTION_TYPE.ACTIVITY_START and/or ACTION_TYPE.ACTIVITY_STOP.
Events fired
("Event" here = an event fired by the xstateful instance, not an event sent to the statechart in a transition.)
before-actions
Fired immediately before all action events for a transition. Not fired if a transition results in no actions.
Fired with one argument: the array of action objects associated with the transition.
action
Fired for a single action emitted as the result of a transition.
Fired with one argument: a ReducerArg (see the "Reducer class" section above). The extstate property incorporates all updates made by earlier actions in the list, and none by later actions.
after-actions
Fired immediately after all action events for a transition. Not fired if a transition results in no actions.
Fired with one argument: the array of action objects associated with the transition.
change
Fired when the machine state or extended state has changed. Fired at most once at initialisation time and for each transition, after all actions are processed.
Fired with one argument: { state, extstate }.
Meta
Inspiration
xstate, by David Khourshidreact-finite-machine, by Derek Duncan
Maintainer
David Smith (@avaragado)
Contribute
Bug reports, feature requests and PRs are gratefully received. Add an issue or submit a PR.
Please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.
Developer notes
The package.json file contains all the usual scripts for linting, testing, building and releasing.
Buzzwords: prettier, eslint, flow, flow-typed, babel, jest, rollup.
Branches and merging
When merging to master Squash and Merge.
In the commit message, follow conventional-changelog-standard conventions
Releasing
When ready to release to npm:
git checkout mastergit pull origin masteryarn release:dryrunyarn release --first-releaseon first release, drop the flag thereafter- Engage pre-publication paranoia
git push --follow-tags origin masternpm publish- not yarn here as yarn doesn't seem to respect publishConfig
Licence
MIT © David Smith
7 years ago