Create a proxy state machine to drive CSS transitions on state changes with XState
In order to add some behaviors to a state machine, we can change it, or we can add another machine in front of it to delegate some responsibilities. In this article I present the machine we added in front of our main machine to manage the load button of our Turing machine visualizer.
This article is the fifth one of a series about our Turing machine visualizer built with XState .
In the Turing machine visualizer we built with Paul Rastoin, we tried to make cool animations, like for load button:
In previous articles, we implemented the request that sends input and machine configuration to server for execution. This request is started when vizMachine
, the machine driving the visualizer, receives a Load
event:
ts
const vizMachine = createMachine({// ...initial: "Idle",states: {"Idle": {},"Executing machine and input": {// Make request to server to execute the machine// ...},"Received response": {entry: "Allow to play execution steps",},"Failed to execute machine with input": {entry: "Allow to play execution steps",},},on: {Load: {target: ".Executing machine and input",},},// ...});
Until now, we assumed that Load
event was sent to vizMachine
when clicking on the Load
button.
Indeed, the origin of the event is the user clicking on the button, but there is a step in-between: submitButtonMachine
.
ts
const submitButtonMachine = createMachine({schema: {events: {} as| { type: "Load" }| { type: "Finished loading" }| { type: "Erred" },},initial: "Idle",states: {Idle: {},Loading: {},Success: {},Error: {},},});
This machine is invoked by vizMachine
as a long-running service, that is kept alive for all the lifetime of vizMachine
(so until the application is closed):
ts
const vizMachine = createMachine({// Root state of the machineinvoke: {src: "Start submit button machine",id: "Submit button",},// ...}, {services: {"Start submit button machine": submitButtonMachine,},});
In vizMachine
, we can reference the invoked machine by its id (Submit button
), especially for sending events to it. We’ll talk about that soon.
submitButtonMachine
is a proxy, and the first thing it needs to do is forwarding events to its parent machine.
permalinkForward Load
event to parent
When in Idle
state and receiving a Load
event, the machine forwards the Load
event to its parent:
ts
import { sendParent } from "xstate";const submitButtonMachine = createMachine({// ...initial: "Idle",states: {Idle: {on: {Load: {actions: "Forward button has been clicked to parent",target: "Loading",},},},Loading: { /** */ },// ...},}, {actions: {"Forward button has been clicked to parent": sendParent({type: "Load",}),},});
sendParent
action is used to send events to the parent machine, that is, the machine that invoked the current one.
To summarize, when user clicks on Load button, Load
event is received in Idle
state and the machine forwards it to its parent, that makes a request to the server.
You may have noticed that when Idle
state receives a Load
event, it targets Loading
state. This is because this machine is meant to orchestrate the animation of load button. And when the request starts, the loading state must be triggered too. We’ll check if these states are active with Vue code to determine which styles to apply.
Note that while the state is Loading
, the machine does not listen to Load
event. That way, we allow only one request at a time.
permalinkWait for response from parent
When user clicks on load button, submitButtonMachine
enters Loading
state. But it must not stay in this state forever. If the request succeeds on parent machine, submitButtonMachine
must go to Success
state, and if it fails, it must go to Error
state.
To know if the request was successful or not, we need to wait for a response from the parent:
ts
const submitButtonMachine = createMachine({// ...initial: "Idle",states: {Idle: { /** */ },Loading: {on: {"Finished loading": {target: "Success",},Erred: {target: "Error",},},},Success: { /** */ },Error: { /** */ },},});
And in parent machine we send to submitButtonMachine
the events it expects when response arrives:
ts
import { send } from "xstate";const vizMachine = createMachine({// Root state of the machineinvoke: {src: "Start submit button machine",id: "Submit button",},// ...initial: "Sending request",states: {"Idle": {},"Executing machine and input": {// Make request to server to execute the machine// ...},"Received response": {entry: ["Allow to play execution steps","Exit loading state from submit button",],},"Failed to execute machine with input": {entry: ["Allow to play execution steps","Enter error state from submit button",],},},on: {Load: {target: ".Executing machine and input",},},// ...}, {actions: {"Exit loading state from submit button": send({type: "Finished loading",},{// Use id of serviceto: "Submit button",}),"Enter error state from submit button": send({type: "Erred",},{// Use id of serviceto: "Submit button",}),},});
To send an event to an invoked service, we use the send
action and provide an option object with to
property that is the identifier of the service.
Apart from the fact that events are exchanged between different machines, the base contract of statecharts remains: events are sent and listened to.
Finally, we make Success
and Error
states active for two seconds and then target Idle
state:
ts
const submitButtonMachine = createMachine({// ...initial: "Idle",states: {Idle: { /** */ },Loading: { /** */ },Success: {after: {"2000": {target: "Idle",},},},Error: {after: {"2000": {target: "Idle",},},},},});
Success and error states of the button will only be shown for two seconds, and then we’ll go back to normal style.
permalinkUse invoked machine in Vue
On Vue side we need to access the current state of submitButtonMachine
, but it’s invoked by vizMachine
and not started as a standalone machine. So how do we do?
All invoked services of a machine are available on state.children
object, referenced by their id:
ts
import { computed } from "vue";import { ActorRefFrom } from "xstate";import { useMachine } from "@xstate/vue";import { vizMachine, submitButtonMachine } from "./machines";type SubmitButtonActorRef = ActorRefFrom<typeof submitButtonMachine>;const { state } = useMachine(vizMachine);const submitButtonService = computed(() => {// In current XState version (v4), services need to be type-castedreturn state.value.children["Submit button"] as SubmitButtonActorRef;});
From here we can use useActor
hook to access the current state of submitButtonService
:
ts
import { computed } from "vue";import { ActorRefFrom } from "xstate";import { useMachine, useActor } from "@xstate/vue";import { vizMachine, submitButtonMachine } from "./machines";type SubmitButtonActorRef = ActorRefFrom<typeof submitButtonMachine>;const { state } = useMachine(vizMachine);const submitButtonService = computed(() => {return state.value.children["Submit button"] as SubmitButtonActorRef;});const {state: submitButtonState,send: submitButtonSend,} = useActor(submitButtonService);
useActor
subscribes to all state changes of a service and returns a computed property always containing the last state value.
And let Vue apply CSS transitions based on the state of submitButtonMachine
:
svelte
<buttontype="button":disabled="submitButtonState.matches('Idle') === false":class="[submitButtonState.matches('Success')? 'text-green-800 ...': submitButtonState.matches('Error')? 'text-red-800 ...': 'text-yellow-800 ...',]"@click="submitButtonSend({ type: 'Load' })"><Transitionmode="out-in"enter-active-class="duration-300 ease-out"enter-from-class="opacity-0"enter-to-class="opacity-100"leave-active-class="duration-200 ease-in"leave-from-class="opacity-100"leave-to-class="opacity-0"><spanv-if="submitButtonState.matches('Idle') === true"key="idle">Load</span><spanv-else-if="submitButtonState.matches('Loading') === true"key="loading">Processing...</span><spanv-else-if="submitButtonState.matches('Error') === true"key="error">Failed</span><spanv-elsekey="success">Loaded</span></Transition></button>
Here’s the result when request fails:
permalinkSummary
We learned that state machines can be invoked by other state machines, and that parent and child machines can communicate between each other. This is another form of separation of concerns than parallel states, we used to combine visualizer controls and request making managements.
We used a machine in front of another one to add a small enhancement, that does not affect the biggest machine. The main machine remains the same, but the overall system is enhanced.
On Vue side, we can access invoked services and get their current state, and communicate directly with them, as if they were the primary state machine.
So that was the last article of the series about Turing machine visualizer showcase! I hope you enjoyed it.
From here, go use XState on your own projects! Once you get it, you can’t stop from using it.
The code of the visualizer is on GitHub.
The series contains the following articles:
- Control tape of Turing machine visualizer with XState
- Orchestrate request for server-side execution of Turing machine with XState
- Prevent flickering loading animation with XState
- Create stale data indicator with XState
- Create a proxy state machine to drive CSS transitions on state changes with XState