MicheleBertoli / react-automata

A state machine abstraction for React
MIT License
1.34k stars 60 forks source link

Reusable State Machines #59

Closed ShMcK closed 6 years ago

ShMcK commented 6 years ago

Recently, I've been using a modified version of react-automata at work. The changes address a problem I've had with trying to connect multiple components to a single statechart.

Using the React 16.3+ Context API you can set a root Provider, and connect multiple components using a generated withStatechart for that specific config.

import createStatechart from './lib/someLib'

const { Provider, withStatechart } = createStatechart(machineConfig)

const A = withStatechart(AComponent)
const B = withStatechart(AComponent)

const someComponent = () => (
  <Provider>
    <A />
    <B />
  </Provider>
)

A pattern like this may be worth exploring in later releases of "react-automata". Unfortunately, it would currently result in some major breaking changes.

MicheleBertoli commented 6 years ago

Thank you very much for opening this issue, @ShMcK. This is an interesting problem, and I don't mind to release a breaking change as long as it adds value for the users of the library.

However, I would like to understand more what are the needs behind this change. Can you please provide a real-world use-case?

ShMcK commented 6 years ago

Context APIs

There's a saying "when you get a new hammer, everything looks like a nail." I think it summarizes my issue with trying to use the new Provider/Consumer Context API with "react-automata".

The old context API can allow more flexibility, and may be worth keeping. It gives you access to the state machine through this.context, whereas the new context API expects you to explicitly pass down your props. Moving to the new Context API would break <State /> & <Action />, or they would have to shift to explicitly receiving the statechart value.


Reusing State Machines

The problem I have is that I would like to reuse a state machine with connected components in different parts of the component tree. I'm working on a complex interface, somewhat similar to photoshop, where toolbars by necessity are located apart.

I realize now there is a much easier fix for this that grants the same flexibility.

Current
const a = withStatechart(statechart)(A)
const b = withStatechart(statechart)(B)

In the above example, a & b are not using the same state machine. They will not be in sync. "react-automata" internally generates the Machine, making each a different instance.

Proposed

An easier fix to allow more flexibility would be to move the creation of the state machine outside of "react-automata":

import { Machine } from 'xstate'

const statechart = Machine({ /* ... */ })

const a = withStatechart(statechart)(A)
const b = withStatechart(statechart)(B)

This might be worth considering.

MicheleBertoli commented 6 years ago

Thank you very much @ShMcK for providing more information. It's already possible to create a machine and pass it to the higher-order component.

However, I'm not 100% sure if you only want to reuse a machine or you also want the components' states to be in sync. I'm asking this because given that xstate is stateless, creating the machine outside doesn't help with the "sync" part.

ShMcK commented 6 years ago

I suppose what I'm looking for is something like xstateful.

alexandrethsilva commented 6 years ago

@MicheleBertoli I may be missing something, but as I see it (and according to what you guys exchanged here) the only way of keeping a machine in sync among different components in react-automata is passing it as a prop to others, right? As it doesn't make use of the newer context API?

In that case the added lifecycle hooks provided by withStateChart is limited to the top-most component only, as even when provided the same machine instance it's unaware of it's state due to it being stateless?

I'm still trying to get my head around react-automata's API and how it combines with xstate in general, as you may have noticed... 😄

ShMcK commented 6 years ago

I've written a somewhat incomplete and unpublished article on the topic, feel free to have a look and drop any feedback. I hope it helps. https://medium.com/@ShMcK/communicating-between-state-machines-components-33e6ab754605

MicheleBertoli commented 6 years ago

@alexandrethsilva, thank you very much for your comment. @ShMcK, thank you very much for writing an article about this.

I think there's a little bit of confusion around the relation between the new context API, sharing the machine state between multiple components, and the concept of external state machines.

In fact, it's already possible to build a Provider-like structure with react-automata, although the library uses the legacy context APIs (which is, and should be, an implementation detail).

For example, here is an app that looks like the example from the article, where multiple AlarmClock components are "in sync":

import { Action, withStatechart } from 'react-automata'

const statechart = { // ... }

const AlarmClock = () => (
  <>
    <Action show="startRing">
      <Ringing />
    </Action>
    <Clock />
  </>
)

const Component = ({ children }) => children

const AlarmMachine = {
  Provider: withStatechart(statechart)(Component),
}

const App = () => (
  <AlarmMachine.Provider>
    <>
      <AlarmClock />
      <AlarmClock />
    </>
  </AlarmMachine.Provider>
)

However, I don't see any difference with the following code, which is what I recommend, and provides the same behaviour:

import { Action, withStatechart } from 'react-automata'

const statechart = { // ... }

const AlarmClock = () => (
  <>
    <Action show="startRing">
      <Ringing />
    </Action>
    <Clock />
  </>
)

const Component = () => (
  <>
    <AlarmClock />
    <AlarmClock />
  </>
)

const App = withStatechart(statechart)(Component)

The main differences between react-automata and xstateful-react are two:

  1. react-automata uses the components methods to respond to actions, while xstateful-react offers a reducer-pattern.

  2. xstateful-react's Provider passes the machine state, extstate, and transition to the <Machine.* /> components. react-automata doesn't, but the same behaviour can be reproduced in userland, if needed.

Here's a variation of the previous example, that uses the new context API:

import { withStatechart } from 'react-automata'

const AutomataContext = React.createContext('automata')

const AlarmClock = () => (
  <AutomataContext.Consumer>
    {({ machineState, transition }) => // ...
  </AutomataContext.Consumer>
)

const Component = props => (
  <AutomataContext.Provider value={props}>
    <>
      <AlarmClock />
      <AlarmClock />
    </>
  </AutomataContext.Provider>
)

const App = withStatechart(statechart)(Component)

If number 2 is what generates this sort of confusion and that's what you mean by "External State Machines", I'm happy to implement it at the library-level.

A couple of notes on the article, @ShMcK:

I hope this helps/makes sense, and I'm happy to discuss further.

rumtraubenuss commented 6 years ago

@MicheleBertoli I have a question to your last comment regarding the example with react's new context API: The AlarmClock components receive the props (machineState, transition) via the AutomataContext.Provider. But what about the lifecycle methods? Those are only added to the AutomataContext.Provider and not to the nested AlarmClock components, or? And what about the onEntry actions defined in the state chart? Those should be called on the AlarmClock components, but I think with this example they also would be called on the AutomataContext.Provider.

MicheleBertoli commented 6 years ago

Thank you very much for your comment, @rumtraubenuss. I'm more than happy to answer your questions.

It's correct when you say that lifecycle hooks and the action methods are fired only in the component wrapped into withStatechart (the Provider in this case).

You should consider that componentWillTransition is fired right before componentWillReceiveProps (now deprecated) and componentDidTransition is basically componentDidUpdate so their behaviour can be easily replicated in the tree.

In fact, given that the returned component is a regular React component (with a few special props and behaviours), nothing stops you from using common techniques (e.g. passing down props) to trigger "reactions" in children. I'm under the impression that the fact this library is just a thin "bridge" between xstate and React is a source of confusion.

Also, a possible "workaround" would be to use State and Action callbacks (not recommended), for example:

<State value="whatever" onShow={this.onEntry} onHide={this.onExit} />
<Action show="whatever" onShow={this.onAction} />

I hope this help, and it would be great if you could share your use-cases so that we can discuss further.

alexandrethsilva commented 6 years ago

@MicheleBertoli thanks a lot for your extensive feedback on these last questions we posted. I didn't have time last week to get back to you, but appreciated very much your reply.

My question after it would be exactly what @rumtraubenuss raised and I agree with you that may have been what confused me at first, as when seeing the description of the additional lifecycle hooks I thought it was intended to be the standard/recommended way of interacting with the automata.

When thinking about it now, after going a few times through this thread to wrap my head around it, I wonder if these hooks are a meaningful part of the library of if their absence could actually contribute to clarifying the idea of this library having only the role of the "thin bridge" you mentioned?

Maybe without them I would have felt less compelled to use the HOC in every component which interacted with the automata, which in turn led me to think that simply having it available on the context level would be incorrect or a not recommended use of it.

I hope I didn't get too lost when making my point? Does it make some sense? And, ultimately, what would be your thoughts on removing the hooks and adding some of the examples you mentioned in this thread to the docs? I'd be glad to contribute with a PR if you think that'd make sense.

MicheleBertoli commented 6 years ago

Thank you very much @alexandrethsilva for providing more context. I'm glad the discussion was useful, and thanks for confirming that the lifecycle hooks are a source of confusion. The cause might also be the Provider/connect approach enforced by react-redux.

I'm currently in the process of rewriting some parts of the library (better APIs, improved performance, support actions/activities) and the README. I will follow your suggestion, and I'll add a FAQ section with the examples from this thread and more.

I don't think we should remove features, as they are useful (e.g. logging in componentDidTransition), but we need to provide clearer information.

I'll ping you when the new stuff is ready, and please feel free to contribute to it.

MicheleBertoli commented 6 years ago

Here's the new FAQ section, @alexandrethsilva. I hope it's useful, and please feel free to contribute to it.

alexandrethsilva commented 6 years ago

Hi @MicheleBertoli, sorry for the delay in replying. 😬

Thanks a lot for the continuous feedback on this. I think the confusion may indeed have been influenced by the way react-redux does things with connect. And I also see your point when you talk about not removing features which may be useful.

Really nice to read that you're bringing more updated to the lib. I've been enjoying using it quite a lot.

And last but not least, thanks for the new FAQ! Whenever something pops that I think may be useful I'll open an issue here to discuss and a subsequent PR if it makes sense 👍