
TLDR
If you are curious about the full-working example, it ishere.
Background
I've been using xState with React for some time already. Throughout the projects' development, I often find myself reusing the same machine in different contexts. Sometimes makes sense tointerpret
the machine from the React component, other times it is the logical choice tospawn
and store it as a childactor
.
That's why I came up with a simple abstraction that let's me use the machine in both ways.
Use case example
Let's say we need a toggle machine for our checkbox component. We create a simple machine withon
andoff
states and then use it directly in our checkbox component via theuseMachine
hook.
But in another page we have a more complex machine, which can make good use of the toggle machine for displaying a notification based on business logic. This sounds as a good opportunity tospawn
a toggle actor, which will be controlled by the complex machine.
Now we need the same machine, but utilised in two different ways.
Checkbox component
Firstly, we need to create our toggle machine.
exportconsttoggleMachine=createMachine({id:"toggleMachine",schema:{events:{}as{type:"TOGGLE"},},initial:"off",states:{off:{on:{TOGGLE:{target:"on",},},},on:{on:{TOGGLE:{target:"off",},},},},});
It is a simplestatechart
withon
andoff
states. This will be sufficient for our checkbox component. We just have to pass it to theuseMachine
hook.
interfaceProps{onChange(checked:boolean):void;}exportfunctionCheckbox({onChange}:Props){const[state,send]=useMachine(toggleMachine);return(<><labelhtmlFor="toggle">Toggle</label><inputid="toggle"type="checkbox"checked={state.matches("on")}onChange={(event)=>{send({type:"TOGGLE"});onChange(event.target.checked);}}/></>);}
We use thematches
method to confirm the machine state and control thechecked
attribute. WhenonChange
is triggered, the machine state is toggled and the callback from the props is fired.
The only problem that we are facing now is that we cannot control the initial state of the checkbox component. It will always be initialised asunchecked
until further interaction occurs.
We can easily solve the issue by lazy-loading the machine. By returning the machine from a factory function, we can pass the initial state as a variable.
The final step, is to lazily-create the machine with theuseMachine
hook. This will prevent the warnings for a new machine instance being passed to the hook on each re-render.
exportfunctiontoggleMachine({initial}:{initial:string}){returncreateMachine({id:"toggleMachine",initial,states:{/* ... */},});}/* ... */const[state,send]=useMachine(()=>toggleMachine({initial}));
Data fetching notification
Supposing we need to show specific data to the user when downloaded. To make it sure that the user won't miss the information, we put it in a notification component.
We can start by creating the fetching machine (parent machine) that takes care of downloading the user info.
constfetchingMachine=createMachine({id:"fetchMachine",initial:"fetching",on:{FETCHING:{target:"fetching",},},states:{idle:{},fetching:{invoke:{src:"fetchData",onDone:{target:"idle",},},},},},{services:{asyncfetchData(){constresponse=awaitfetch("https://jsonplaceholder.typicode.com/todos/");constdata=awaitresponse.json();returndata;},},});
Similarly to theCheckbox
component, theNotificaion
one needson
andoff
states, in order to control its visibility. The difference now is that instead of interpreting the machine directly in the component, we canspawn
anactor
and keep it in the fetchingmachine
's context.
In my opinion, this approach gives us more flexibility when it comes to controlling the toggle machine. We canspawn
the machine explicitly when needed, instead of relying on the React render cycle.
constfetchingMachine=createMachine({/* ... */states:{idle:{},fetching:{invoke:{src:"fetchData",onDone:{actions:["assignToggleRef"],target:"idle",},},},},},{actions:{assignToggleRef:assign({toggleRef:(context,event)=>{returnspawn(toggleMachine({initial:"on"}));},}),},services:{asyncfetchData(){/* ... */},},});
We can simply pass thetoggleRef
to theNotification component
and interpret it from there with the guarantee that the fetching of the data is completely finished.
interfaceProps{actor:ActorRefFrom<typeoftoggleMachine>;}exportfunctionNotification({actor,data}:Props){const[state,send]=useActor(actor);return(<div>{state.matches("on")&&<div>Veryimportantnotification</div>}<buttononClick={()=>{send({type:"TOGGLE"});}}>{state.matches("on")?"close":"open"}</button></div>);}
Now we have access to the actor'sstate
andcontext
. Also, since the actor reference is kept in the parent's machine context, we cansend
events to the interpreted actor regardless of the parent's machine state.
Unite both worlds
Using directly the exported machine is perfectly fine, but I fancy adding one more layer of abstraction.
import{ActorRefFrom,createMachine,spawn}from"xstate";exporttypeToggleMachineActor=ActorRefFrom<typeoftoggleMachine>;functiontoggleMachine({initial}:{initial:string}){returncreateMachine({id:"toggleMachine",/* ... */});}exportfunctiontoggleMachineCreate({initial="off"}):{machine:ReturnType<typeoftoggleMachine>;spawn:()=>ToggleMachineActor;}{constmachine=toggleMachine({initial});return{machine,spawn:()=>spawn(machine,{name:"toggleMachine"}),};}
Now, ourtoggleMachineCreate
returns both the machine instance and the spawned actor, which I find a bit cleaner when it comes to using the machine.
toggleRef:(context,event)=>{returntoggleMachineCreate({initial:"on"}).spawn();}/* or */const[state,send]=useMachine(()=>toggleMachineCreate({initial}).machine);
Another advantage of this approach is that we have a convenient place where we can extend the machines with thewithContext
andwithConfig
utility methods. This gives our code а pinch of readability.
exportfunctiontoggleMachineCreate({initial="off",specialService=Promise<any>}):{machine:ReturnType<typeoftoggleMachine>;spawn:()=>ToggleMachineActor;}{constmachine=toggleMachine({initial}).withConfig({services:{specialService},});return{machine,spawn:()=>spawn(machine,{name:"toggleMachine"}),};}
On top of that, you can easily pass arguments to thespawn
function too. That might be helpful when multiple actors from a machine are spawned and stored into a single context. Distinguishing the references by name, might be helpful when it comes to communication with them.
exportfunctiontoggleMachineCreate():{/* ... */return{machine,spawn:(name:string)=>spawn(machine,{name}),};}/* ... */actions:{assignToggleRef1:assign({toggleRef1:(context,event)=>{returntoggleMachineCreate().spawn("toggleMachine1");},}),assignToggleRef2:assign({toggleRef2:(context,event)=>{returntoggleMachineCreate().spawn("toggleMachine2");},}),},/* ... */
And last but not least, I find it really handy to have the actor type exported along with thetoggleMachineCreate
function. It is quite useful when it comes to prop drilling the actor and defining the prop types.
exporttypeToggleMachineActor=ActorRefFrom<typeoftoggleMachine>;/* ... */interfaceProps{actor:ToggleMachineActor;}
Opinionated conclusion
This pattern scales greatly and gives a good amount of predictability in regards to code organisation and readability.
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse