statelyai / xstate

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

Interpreter.start() does not return initial state #311

Closed createthis closed 5 years ago

createthis commented 5 years ago

Bug or feature request?

Bug

Description:

Interpreter.start() does not return initial state. It appears to return the Interpreter object.

(Bug) Expected result:

Should return initial state.

(Bug) Actual result:

Returns Interpreter object.

(Bug) Potential fix:

Should either return initial state or else the documentation needs to be updated.

davidkpiano commented 5 years ago

The interpreter does not have a method called .state(). It has a property called .state that indeed gives the current state of the service (interpreter instance). Where in the docs do you see otherwise?

If you want the initial state, use service.initialState.

createthis commented 5 years ago

@davidkpiano Sorry, that was a typo. I meant .start()

createthis commented 5 years ago

@davidkpiano I think I understand what I was confused about now. On this line:

https://github.com/davidkpiano/xstate/blame/master/docs/README.md#L48

it says:

// => 'inactive'

I thought that meant start() returned the state, like in an interactive js console or something. I guess you intended it to mean that 'inactive' would be returned by the console.log() in the onTransition callback?

At this point, I could get the state like this:

const toggleService = interpret(toggleMachine)
  .onTransition(state => console.log(state.value))
  .start();

let state = toggleService.state.value

Is that the preferred way to query the state at any given point in time?

davidkpiano commented 5 years ago

Is that the preferred way to query the state at any given point in time?

In general, it is not preferred to arbitrarily query the state. You should subscribe to state changes in any reactive system, especially with statecharts.

It is a mutable property that is updated when the state is changed. Query it only when absolutely necessary. Prefer using .onTransition().

Instead, it is always guaranteed that the first state is the .initialState:

const toggleService = interpret(toggleMachine)
  .onTransition(state => console.log(state.value))
  .start();

let state = toggleService.initialState;
createthis commented 5 years ago

In the world of async/await, this makes me a little uncomfortable. But I admit React's own setState with it's callback makes me uncomfortable too.

I like to avoid race conditions. I feel like there is more potential for that sort of thing when you're firing callbacks in the background all the time.

I also like to minimize setState calls as much as possible. It's difficult to batch an xstate send() and setState if you're worrying about the onTransition callback.

davidkpiano commented 5 years ago

In the world of async/await, this makes me a little uncomfortable.

Keep in mind that async/await is only for single eventual values; e.g., a resolved value from a Promise. That is not the case with how statecharts work, which are more like Observables.

I.e., await service.send('SOME_EVENT') makes no sense because:

I like to avoid race conditions. I feel like there is more potential for that sort of thing when you're firing callbacks in the background all the time.

Race conditions, within the context of a single statechart/state machine, are mathematically impossible. It's only when coordinating between multiple statecharts that race conditions can occur, depending on how you orchestrate events between each, but this can be avoided with an event-based architecture (including statecharts themselves).

Example:

const service = interpret(someMachine).start();

// assume we have some async remote streams
stream1.subscribe(event => service.send(event));
stream2.subscribe(event => service.send(event));

Those two streams, stream1 or stream2 can emit events at any time and they'll be processed in order by the service. Since both streams (which can themselves be events emitted from other statecharts) are orchestrated by a single state machine (someMachine), we can successfully avoid race conditions.

I also like to minimize setState calls as much as possible. It's difficult to batch an xstate send() and setState if you're worrying about the onTransition callback.

See the section on executing actions manually in the docs. You have full control over this behavior.

createthis commented 5 years ago

Yes, I see that xstate is very opinionated. I admittedly don't use 99% of the functionality it has to offer. I only use it to tell me the current state and the next state, given an event. I also appreciate the ability to generate visualizations so I can see my application's state flow at a glance. The rest of it is pretty meh to me as I handle it all externally of xstate.

Anyway, thanks for taking the time to talk it through with me. I appreciate it.

davidkpiano commented 5 years ago

That's totally fine. It follows the SCXML spec so the opinions are not mine 😉 Using it as a pure transition() function that gives you the next state given the current state + event provides the most flexibility. You are free to make your own interpreter and handle everything else externally.

createthis commented 5 years ago

Doing a quick search, it looks like there are promise based FSM libraries out there for ES6: https://github.com/vstirbu/fsm-as-promised

If it had xstate's visualization capability, I might be tempted. ;)

davidkpiano commented 5 years ago

Look closely. Ironically, fsm-as-promised uses callbacks primarily.

I'm not trying to convince you to use XState though - open source is a collaboration, not a competition. Here's a few other libraries for you to consider:

createthis commented 5 years ago

Looks to me like each event becomes a chainable promise returning method to me.

davidkpiano commented 5 years ago

I see that, and it makes no sense. With statecharts and state machines, "...sending a signal takes zero time" (according to David Harel in his original paper). Also:

Transitions, assignment statements, signal transmitions, etc., are all assumed to be instantaneous, i.e., to take zero time.

_David Harel, http://www.inf.ed.ac.uk/teaching/courses/seoc/2005_2006/resources/statecharts.pdf_

So the fsm-as-promised example:

fsm.warn().then(function () {
  fsm.current === 'yellow';
  // true
});

Would be written like this in xstate:

fsm.send('warn'); // zero time!
fsm.state.value === 'yellow'; // true
createthis commented 5 years ago

I think vstirbu is using promises because fsm-as-promised allows the definition of callbacks. These are called when the event is sent. In xstate, the equivalent are side-effects. It makes perfect sense to return a promise from send() and resolve it when all the side-effects have completed successfully.

I, and I think (for at least 5 years now) most of the JS community at this point, prefer promises to the Observer pattern. Now, with async/await in ES6, anything that returns promises gets to use await for free. So this:

fsm.warn().then(function () {
  fsm.current === 'yellow';
  // true
});

Becomes this:

await fsm.warn();
fsm.current === 'yellow'; // true

It makes sense, once you get past the idea of being bound to the observer pattern.