Skip to content

Best way to avoid invoking promises multiple times (or keeping context up to date) #124

Closed as not planned
@KidkArolis

Description

@KidkArolis

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:

  1. 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).
  2. 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
    ),

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions