[react] Embedded state machines & service injection -- includes working code #4096
Replies: 1 comment 10 replies
-
I thought about it a bit more, I think one possible solution to the issue of a final state for the machine is to separate the managing machine from the child machines at the top level / where it interfaces directly with a component. Then passing in a self destruct method / link to the parent / managing machine's state and then passsing that to an onTransition monitor. That way when the child reaches a done state it will autmatically unmount itself. This would allow react the handle the cleanup. For example, say we have a managing machine (Manager) a child (Parent) and an embedded machine (Embedded) What I proposed in the first comment is that Parent be embedded into Manager and Manager interface with a react component to show and hide the relevant UI for the given state. However, this is a problem because the Manager provider depends on teh Parent provider. So if the Manager provider is not unmounted neither can the Parent nor Embedded. In the case I've described, it would basically allow Manager and (Parent + Embedded) to be two separate entities wrt react. Thus the Manager manages which child (Parent + Embedded) is mounted for a given use case. To do this you could do logic similar to this. const Manager = () => {
const { messenger, isToggling } = useManagerContext()
if (isToggling) {
return <Toggle next={messenger.next}/>
} else {
return null
}
} and then the Toggle component can be something like this const Toggle = ({ next }) => {
// note that useToggleMachine has another machine embedded in it
const [..., service, ..] = useToggleMachine({ ...services })
service.onDone(() => next())
return (
...provider logic
)
} Therefore the child component directly manages its own life-cycle wrt the manager, the manager's machine may look something like.
Thus when the manager has next sent to it it effectively unmounts the child. That way everytime that UI loads you get a fresh machine. This seems to also be the more effecient method as well. |
Beta Was this translation helpful? Give feedback.
-
I have been working with xstate for a bit and I've found that it's excellent in managing the lifecycles of my components. However, the definitino of the state machines can get very cumbersome and difficult to manage for complex systems. Furthermore, the interaction between nested / embedded machines sometimes doesn't work as expected.
I have also run into bugs during my investigation of this topic. In short, this is not an explicit bug more of an investigation into the uses of xstate. But here are few things I've noticed:
You can see my sandbox here: https://codesandbox.io/p/sandbox/machine-generators-forked-r8zwzz?file=%2Fsrc%2Findex.tsx%3A6%2C44
but it can get quite verbose (I'm sure it could be made significantly smaller with some tidying). And the overall use case is quite complex (in my opinion). So I'll explain what's going on in the sandbox.
Basically, the sandbox demonstrates a parent and a child machine. Each of which is accessible via its own event api; and each of those apis are available from any child react component to the providers (i.e. via context). If you first transition the parent to active (by clicking it) you'll notice that the number of calls does not increment. In case something happens with the sandbox consider the following demonstration.
[inactive, parent: 0] [inactive, child: 0]
where parent represents the parent component and child represents the child. the number represents the relative count value for each context. Each [] can be considered a button and I'll show the state changes. The interesting behavior is that when you first click the parent it becomes
[inactive -> active, parent: 0 -> 0] [inactive, child: 0]
but then when you click the child it will transition to active, at this point it will then transition to a "done" state. onDone is then called and a referenced promise is resolved with the contents of the done event. so now it's
[active, parent: 0] [inactive -> active -> done: 0 -> 1 -> 1]
this done event then propogates to the parent and the parent then increments its own value, when this happens it then transitions the parent to done as well
[active -> done, parent: 1 -> 1] [done, child: 1]
If you were to instead not transition the parent initially, then you transition the child, the child will resolve to done, but the parent will not. In this case the parent can then transition back and forth as normal but the value will always remain 0 as it is not having its onDone method called.
This demonstrates the ability for the child to provide cascading effects on its parent. In addition, the code I've provided allows the definition of the parent component to access / control the child with an injected api. In particular (if you reference the code) you'll see that there are machines["machine"].messenger.event() that can be called thus allowing the parent to control the state of the child.
This design pattern allows for complex machines to be defined in easy to understand ways. In particular, it allows for clean architecture principles like separation of concern to be applied. Here is an example file structure that implements a complex sign-up flow.
This implements (roughly, I first built the machine diagram then transferred that to code, so some stuff has changed). Here is the relevant machine if you'd like to visualize it. By the way, the inspector does not work for visualizing this in real-time / in development.
This then allows for the pattern where the component itself implements only the UI logic and can only mutate the state via event emitting. Perhaps this is a bit of over-engineering, but imo it's a simple and scalable approach.
The only problem I've been stuck on recently is that I don't know how to reset the child machines once they react a final state. Ideally they would generate a new instance if they are invoked when in a done state. But I haven't had success in emitting reset events or the like. I suspect the only feasible approach is via force remounting the children but that starts to get into territory where I start to think I'm adding complexity rather than obfuscating it. @Andarist you reference this idea here: #2108 (reply in thread)
Please note that my ideal use case here would be some kind of redux toolkit equivalent for xstate.
In conclusion, I have implemented a few helper functions to aid in the development of complex xstate machines. I believe that xstate realisitically needs to have both the ability to do complex state machine embedding and service injections for it to be compatible for dynamic react use cases. In particular, in the code I've provided, it allows for the definition of xstate's services to be defined within a component. i.e. it has access to hooks. So in my case I often use this for managing state like redux or whatever else. But it also allows for xstate to invoke side-effects like a toast or something to that effect. In general it's (in my opinion) the cleanest approach to managing complex state logic.
Regardless, this has mainly been a curiosity for me, I'm sorry if it's not a new idea or I'm repeating things.. But I'm a bit worried that I'm adding more complexity than I'm removing. Still, a fun exploration nonetheless.
p.s. the files in the codesandbox call for typescript to ignore them. But in my IDE of choice (intellij) the types are all strongly typed with nothing lost (to my knowledge) and I ignored the files for simplicity.
Beta Was this translation helpful? Give feedback.
All reactions