statelyai / xstate

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

[Feature] Interpreter (event emitter) #50

Closed davidkpiano closed 5 years ago

davidkpiano commented 6 years ago

Related to #36 cc. @mogsie

Provide a separate interpreter which will act as a simple event emitter for which to run a machine. This is technically side-effectful, but it is not a core part of how xstate works (re. the transition function) nor is it required. The developer should always be allowed to implement their own runner.

Proposed API

import { Machine, Interpreter } from 'xstate';

const myMachine = Machine({ ... });
const interpreter = Interpreter(myMachine);

const eventListener = (state: State) => { ... };

// add event listener
interpreter.on('transition', eventListener);
// or interpreter.onTransition(eventListener); perhaps

// remove event listener
interpreter.off(eventListener);

// dispatch event to statechart
interpreter.dispatch({ type: 'FOO', data: 123 });

// start interpreter on initial state
interpreter.init();

Related to the proposed delayed actions syntax, we could have something like:

const machine = Machine({
  initial: 'foo',
  states: {
    foo: {
      onEntry: [
        { type: 'TIMER', delay: 4000 } // send 'TIMER' event after 4s
      ]
    }
  }
});

const interpreter = Interpreter(machine);
interpreter.on('action', a => console.log(a));
interpreter.init();

// logs { type: 'TIMER', ... } after 4s

Usage with a reactive library, such as RxJS:

// ... from first example
const listen = handler => interpreter.on('state', handler);
const unlisten = handler => interpreter.off(handler);

const state$ = fromEventPattern(listen, unlisten);

state$.subscribe(e => console.log(e));

interpreter.init();

Thoughts? Very open to feedback on this one.

mogsie commented 6 years ago

Looks great. I guess there would be an on('start', ...) and on('stop', ...) whenever an activity is started or stopped (given the activity proposal). I love the fact that this ends up hiding the delayed events. Such a runner will make it a lot easier to start using xstate.

mogsie commented 6 years ago

Come to think of it, starting and stopping activities might be better handled by being able to specify the activity to start/stop in the handler:

interpreter.onStart('http_request', () => xhr.start());
interpreter.onStop('http_request', () => xhr.cancel());
interpreter.onStart('loading', (e) => this.addClass(e));  // e would be the string 'loading'
interpreter.onStop('loading', this.removeClass);
davidkpiano commented 6 years ago

Yeah @mogsie that's a good idea. For desugaring, I'd also like to be compatible with the (proposed) Observable spec so that for all statechart-specific events:

const interpreter = Interpreter(machine); // implements Observable interface

// This will also .init() the interpereter
const subscription = interpreter.subscribe(statechartEvent => {
  // all statechart events will be passed into the subscription
});

// unsubscribe
subscription.unsubscribe();

So interpreter.onStart(...) would be sugar for interpreter.filter(e => e.type === 'start') or something similar. But yes, "throughout" activities would work just as you've described.

lmatteis commented 6 years ago

Cool so with observables we can compose. If we think abstractly a statechart will take as inputs events, and will output actions (activities can simply be "start X" actions).

We could handle this with functions which take a stream of actions and return a stream of events. Actions in, events out. So these functions will take the output of a statechart (action) and give us back something we can feed back into the statechart (events).

function handleHttpRequest(action$) {
  return action$.filter(action => action.start === 'http_request')
    .mergeMap(() => 
      ajax('http://google.com') // perform the actual http request
        .mapTo({ type: 'HTTP_SUCCESS' }) // send this event to the statechart
        // note how we can stop the observable (takeUntil) when a "stop http_request" is output
        .takeUntil(action$.filter(action => action.stop === 'http_request')) 
    )
}
function handleLoading(action$) {
  return action$.filter(action => action.start === 'loading')
    .mergeMap(() =>
       // can return an object that is passed to setState()
       setState({ loading: true })
         .mergeMap(
           action$.filter(action => action.stop === 'loading')
             .mapTo({ loading: false })
         )
    )
}
statechart.subscribe(
  handleHttpRequest,
  handleLoading
)

I'm just bikeshedding my thoughts here 🤔

davidkpiano commented 6 years ago

Great idea - the .subscribe syntax doesn't follow the Observable spec though (if it took 3 arguments, it'd be onNext, onError, and onComplete). But you brought up a good point of needing a way to "input" actions, so Interpreter(...) can take two arguments:

  1. machine: Machine - the statechart machine to use
  2. actions: Iterable - in this case, it's an Observable stream of actions, but it can just as well be an array or generator

To implement the feedback loop, I'd recommend using either a Subject (with interpreter.subscribe(subject$)) or the expand(...) operator (with using machine.transition inside of the scan(...) operator).

Here's how it would look with the above changes:

const machineEvent$ = new Rx.Subject();
const event$ = // ... all the external events
const allEvent$ = merge(event$, machineEvent$); // RxJS merge creator

const state$ = Interpreter(machine, allEvent$); // hot observable if allEvent$ is hot
state$.subscribe(machineEvent$); // feedback loop
state$.subscribe(state => {
  render(state); // render UI based on state
}

Note that right now, you don't need a special Interpreter to have a statechart interpreted with RxJS - you can use the scan(...) operator:

const action$ = // ...
const state$ = action$.pipe(
  // or just .scan(machine.transition)
  scan((state, action) => machine.transition(state, action), machine.initialState)
);

I'm going to play around with using the expand(...) operator to do a feedback loop - might be more complicated than just using Interpreter(...) and passing the "feedback" proxy Subject in.

(just jotting down ideas too - Observables seem like the best pattern to use with statecharts)

davidkpiano commented 6 years ago

This will be done in the xstate-interpreter package.

azohra commented 6 years ago

Love the ideas in this feature request, is it still planned?

davidkpiano commented 6 years ago

It is, yes.

ccnokes commented 6 years ago

Is there a way currently to observe state changes? (xstate-interpreter doesn't look done). I'm looking at this as a solution to coordinate state changes between different components. It seems like being able to observe state changes and then do side-effect-full things is pretty core to getting work done with a state machine.... but maybe there's a better way?

davidkpiano commented 6 years ago

There will be. What are the use-cases you're specifically looking for? E.g.,

And what's your ideal API for how this would look? There's currently an interpreter in the main branch (not done yet), so the above might look like:

const interpreter = interpret(machine);

// when the state changes
interpreter.onTransition(nextState => ...);

// TENTATIVE
// when extended state changes
interpreter.onChange(extState => ...);

// when a specific value in extended state changes
interpreter.onChange('someProp', somePropValue => ...);

// when an action is fired
interpreter.onAction(action => ...);

// when a specific action is fired
interpreter.onAction('focusInput', focusInputAction => ...);

// when an activity starts/stops
interpreter.onActivityStart('someActivity', activityAction => ...);
interpreter.onActivityStop('someActivity', activityAction => ...);

// when the machine reaches a final state
interpreter.onDone(finalState => ...);
ccnokes commented 6 years ago

@davidkpiano sorry for the late response -- I'm hoping to observe finite state changes + the action that transitioned them to that state, so it looks like the API you have there would cover that.

davidkpiano commented 5 years ago

Closing - the interpreter is feature-complete for 4.0 (though more features may be added for 4.x)