statelyai / xstate

Actor-based state management & orchestration for complex app logic.
https://stately.ai/docs
MIT License
27.13k stars 1.25k forks source link

Question - mental model for invoke and inter-machine communication #320

Closed dakom closed 5 years ago

dakom commented 5 years ago

It seems that there are 3 ways to organize different machines for communication between them:

  1. Hierarchical
  2. invoke()
  3. Manual reference

Is the following correct?

Hierarchical: Helps to keep a machine tidy by taking a bunch of state that can be clustered, and making it into one cluster. These sub-machines are really part of the parent machine and could have been written at the top level; hierarchy is simply a technique to make "state explosion" much more manageable (and it becomes a huge difference considering how the API provides this, e.g. sub-state matching and transitions)

invoke(): Launches something like a temporary machine. The Promise example in the docs is a good concept here - you wouldn't want to have one Promise for an app, often you want to create new Promises and respond to them as they finish. Invoke allows creating and then responding to sub-machines created on the fly

Manual reference: This isn't really part of the API, but it's notable that an interpreter can be created, and then referenced directly from another machine, for example in its actions. This allows for truly separating and communicating between different components in an app.

dakom commented 5 years ago

I think I got hierarchy wrong - hierarchy is not about multiple machines, but rather grouping states within one machine (see #322)

davidkpiano commented 5 years ago

For hierarchical and invoke, that's correct, for the most part. However...

This isn't really part of the API, but it's notable that an interpreter can be created, and then referenced directly from another machine ...

⚠️ This is not recommended, because there is no deterministic way to statically interpret/model that, and it makes it much more difficult to visualize/analyze. Always use send(...) or sendParent(...) to communicate between statecharts.

dakom commented 5 years ago

Thanks... but isn't manual references how you communicated in the Todo MVC ?

For example these handlers which get called by the TodoMachine actions:

onChange={todo => send({ type: "TODO.COMMIT", todo })}
onDelete={id => send({ type: "TODO.DELETE", id })}
davidkpiano commented 5 years ago

Ah, I mean that you shouldn't reference other services (interpreted machines) from a machine:

// ⚠️ DO NOT DO THIS ⚠️

{
  actions: () => {
    // ❌ do not reference other services directly in actions!
    fooService.send('SOME_EVENT');
  }
}

Instead, you should use send or sendParent:

{
  invoke: {
    id: 'foo',
    src: // ...
  },
  on: {
    TRIGGER: {
      actions: send('SOME_EVENT', { to: 'foo' })
    }
  }
}
dakom commented 5 years ago

But in your Todo MVC example, you are calling service.send in the action, no?

In other words, the send being passed in the onChange/onDelete handlers above is not actions.send but rather fooService.send

I don't see a way to link to specific lines on codesandbox so I'll include the relevant snippets (and cut out the surrounding parts), hope this makes sense!

useMachine.js

return [current, service.send];

Todos.jsx

const [state, send] = useMachine(persistedTodosMachine);

<Todo
  onChange={todo => send({ type: "TODO.COMMIT", todo })}
  />

Todo.jsx

todoMachine.withConfig(
      {
        actions: {
          notifyChanged(ctx) {
            onChange({});
          }
        }
      },
davidkpiano commented 5 years ago

service.send(...) is never called in the actions, nor in the machines. It is only called by the UI:

<Todo
  onChange={todo => send({ type: "TODO.COMMIT", todo })}
/>
dakom commented 5 years ago
actions: {
  notifyChanged(ctx) {
    onChange({...}); //<-- is this not calling service.send from the actions?
  }
}
davidkpiano commented 5 years ago

Maybe, maybe not. You can easily have this:

<Todo
  key={todo.id}
  todo={todo}
  onChange={todo => {
    someOtherWayToCommitTodo(todo);
  }}
  onDelete={id => {
    someOtherWayToDeleteTodo(id);
  }}
/>

That is, both the TodoMachine and the <Todo> component are completely agnostic as to the implementation details. They're just responsible for calling onChange and onDelete - they don't know that they're sending an event to a statechart owned by the parent <Todos>.

dakom commented 5 years ago

ahhh okay... so you're talking more like in terms of design goals, not in terms of bugs that could be created due to holding strong references or something?

i.e. it's technically fine to call service.send from an action, but when doing so one should be aware of the strong coupling and how it could make things messy to detangle later?

davidkpiano commented 5 years ago

I wouldn't go as far as to say it's "technically fine" - it should be avoided. Decoupling is a primary concern of statecharts.

dakom commented 5 years ago

Gotcha, thanks again!!

LunarLanding commented 5 years ago

Hi, I am having a similar issue. I'm attempting to formalize a game with a dynamic and undetermined number of players with xstate. However, since I cannot invoke from an action, and therefore create additional submachines/services on demand, this isn't possible. And then I must regress to using a strategy like the one in the Todo example. Ideally there would be notation in statecharts for the dynamical creation of parallel states or invoking in an action.

davidkpiano commented 5 years ago

...dynamic and undetermined number of players

Using context would be the best fit here. This might be better discussed in the Spectrum statecharts community.

LunarLanding commented 5 years ago

Alright. I have opened a thread about this; the tldr is that the state chart formalism cannot yet represent a dynamical list of state machines.