Description
Hi, thanks for a great library, been enjoying learning to use it.
I don't know how common this is, but I want to keep various bits of data (props, data from other hooks) in the context and I want to keep them up to date even if I'm in a middle of invocation.
To do so, I've added an 'assign' transition to every state:
const close = ctx => ctx.onClose()
const assign = (ctx, { type, ...data }) => ({ ...ctx, ...data })
const assignable = name => transition('assign', name, reduce(assign))
const machine = createMachine({
initialising: state(
immediate('configure', guard(loadingDone)),
immediate('initialisingFailed', guard(loadingError)),
transition('close', 'closing', action(close)),
assignable('initialising'),
),
initialisingFailed: state(
transition('close', 'closing', action(close)),
assignable('initialisingFailed'),
),
configure: state(
transition('close', 'closing', action(close)),
transition('save', 'saving'),
assignable('configure'),
),
saving: invoke(
save,
transition('done', 'closing', action(close)),
transition('error', 'configure', reduce(assign)),
transition('close', 'closing', action(close)),
assignable('saving'), // <--- a problem! because transitioning back to itself will invoke again
),
closing: state(),
})
A few thoughts / questions / learnings from this so far:
- Having to manually add assignable to each state is a bit tedious, but not too bad. Given that you might always want to keep context up to date wrt to external data (e.g.
onClose
function passed via prop, or some other bits of context), wondering if there should be an easier way to update the context (in xstate, I think you can do that by handling an even in the root machine). - My assignable helper seems "unidiomatic", because I have to specify the name of the state I'm adding the transition to. There is no way to transition to self without knowing the name of the state. and so I have to pass the name of the state to each
assignable
call. Wondering if self transitions could be made easier.. Or perhaps the first problem is solved in a different way, this would also go away.
I'm not really saying these are even a problem, I think it's good to be explicit and keep the rule set small and simple.
But the next bit is more challenging. The issue is that if you're in the saving
state and send 'assign' event to update context (if say the parent component rerendered passing a new onClose
prop), you invoke the save
function again, but we don't want that in this case of self transition. Now, the best way I found so far to avoid this was to create a custom invokeOnce
helper, which only invokes the function on entering the state and not on "self" transitions:
const valueEnumerable = value => ({ enumerable: true, value })
const create = (a, b) => Object.freeze(Object.create(a, b))
const invokePromiseOnceType = {
enter(machine, service, event) {
const name = machine.current
const prev = service.machine.current
if (prev !== name) {
this.fn
.call(service, service.context, event)
.then(data => service.send({ type: 'done', data }))
.catch(error => service.send({ type: 'error', error }))
}
return machine
},
}
export function invokeOnce(fn, ...transitions) {
const s = state(...transitions)
return create(invokePromiseOnceType, {
fn: valueEnumerable(fn),
transitions: valueEnumerable(s.transitions),
})
}
Only sharing all this to get feedback from any current / future users of robot about how they handle this sort of stuff.
For example, would it be better if invoke always triggered on enter only, and to allow invoking multiple times you'd have to transition out and back in?
Or should there be a way to guard an invoke? (not sure that's semantically correct).
Or perhaps another way would to solve this is to use an intermediate state:
configure: state(
transition('save', 'save'),
assignable('configure'),
),
save: invoke(
save,
immediate('saving')
),
saving: state(
transition('done', 'closing', action(close)),
transition('error', 'configure', reduce(assign)),
assignable('saving'), // no longer a problem, since we're no longer in an invoke state
),