Split State Machine without Parent-Child Relationship
I’m building editor, something like page builder where you can draw object as you do in Figma, but it will automatically be converted into 100% working responsive HTML.
It’s in the early step as of this writing.
But I already have working prototype of it, but as in my other prototypes, I won’t care much about how the code are structured, speed and proof of concept is number one priority.
Now that I have some core editor experience in place after the prototyping.
I want to refactor the codebase, so it won’t look like a giant mess.
I mean, it’s already pretty clean where the core state are handled in isolation inside reducer.
And any UI logic are handled mainly inside individual component who responsible for that specific need.
And also inside custom hooks for something that can be used in many different places.
But still, I don’t think that will scale well, especially if I start adding new features and more people might works on the codebases in future.
Everything is hidden, no one can show you immediately how those parts are working together.
Finding Sensible Way of Organizing The Code
I’m a huge fan of simplicity, I do not want to over-engineer things. But at the same time I do not want code that hard to reason about.
I’ve been touching Redux and using Mobx heavily in the past.
Mobx is fairly simple, yet in large codebase, it can grow into monster where everything talk to each other and there’s no clarity. You can modify in a place and you will break something else in different places.
And if you do not have test in place, it is death sentence, you might end up in endless bug cycle.
You have 9 bugs, you fix 1, now you have 15 bugs. ~ Anonymous
I’m more inclined toward Redux, as I love how the one way data flow they borrowed from the original Flux pattern.
It make the flow of the data is much easier to reason about.
But, I’ve heard other state managements as well, so I revisit some of them, like zustand, jotai, xstate, etc.
Amongs those options, xsate is what catched my attention the most.
Mainly because its finite-state-machine concept and it’s visualization.
It seems that this is the next evolution of state management, I think it might be a good advancement for Redux.
So, I decide to learn it during weekend, and try to refactor my app with it.
It took my ~ 1 week to refactor my fairly early and simple codebase into xstate.
Actually it will be easier had I just put all the logic in a single big machine. But I do not think it is a good way to organize the code, and it looks complicated in the visualization.
I want to break the machine into smaller machines, each with it’s own context and logic, but let them talk to each other easily and inside statechart.
So it will be much easier to work with, we can split these machines into some set of funtionality.
And we can look each’s machine visualization clearly, because they only have steps and transition they are responsible for. Easier to comprehend.
In trying to achieve it, I got roaller coaster feeling.
I often think, xstate is great! and then oh xstate sucks! oh no, xstate is amazing, oh wait, it still produce errors that they promise to get rid of. Oh wait they’re right!
First, in xstate official documentation, they mention that we can split machines by simply using spread operator.
Well, it is splitted in code, but actually it is still single machine.
It shares context, it won’t scale well on large machines.
There’s possible naming conflict in the context.
Another option is we might want to use parent-child relationship to split our big machine into smaller ones.
But when we use child machine, we invoked a machine from parent machine.
This will make the child machine operate inside the boundary of the parent machine.
But what’s the problem with it?
First, We can’t send event directly to child machine from outside. We need to send it to parent machine, which will forward it to the child machine.
Derived from first problem, it means that we have to support child’s events in the parent machine, we need to redefine those events in the parent.
Even if we use autoForward, we still need the parent to support child’s machine events. In typescript, we also need to define those types of event in the parent machine.
Third Problem: Tight Coupling
When a child machine wants to send event back to parent, it need to use sendParent function. This will make a child machine always be a child machine. We can’t use it as a standalone machine.
A parent machine can’t read child’s current state value. Unless explicitly shared by child machines via sendParent. This is painful, it complicate simple things. IMO
Since we can’t access child state value, we might not be able to use it in guards, actions or services. (outside transition)
Unless we have something in parent’s context to represent child’s current value, but it means we do unecessary context duplication.
What if we want to just split our big machine into smaller machines, but still let them read and send events to each other? and without sharing single context.
We can model this types of machine to machine communication. If we read other machine’s current value inside actions, guards or services, it will be captured in the visualization which is nice!
Even if we do not let those machines talk to each other inside their own machinery, they will still talk to each other outside the state machine. Meaning, those “glue code” won’t be captured in the visualization and can be a serious blind spot.
With root machine pattern, every machine can send events to other and read eachother’s state via rootMachine. The rootMachine holds reference to all the machines who intends to work together.
We also send events directly to the each machines based on portion of app’s logic they are responsible for. No need to define all the machine’s event in the root machine.
No glue code outside these machines needed, less blind spot, easier to reason about.
Root machine pattern can be helpful to split machine into smaller machines, this pattern enable many machines have isolated logic and context and yet still can communicate to each other via root machine.
They can read’s another machine state’s value inside their actions, guards or services.
Everything can happen inside statechart.
All captured and can be visualized.
Thank you for reading. I hope it is helpful, if you have any question or feedback feel free to leave a comment below.