DEV Community

Mikey Stengel
Mikey Stengel

Posted on • Edited on

State machine advent: Everything you need to master statecharts (24/24)

1. Use TypeScript

Even though state machines help us eliminate lots of bugs, there can still be type errors that are difficult to catch on your own. The type definition of XState is really good. As a result, you do not only get amazing IntelliSense and autocompletion, TypeScript will shout at you whenever your machine definition is not in line with the types you created.

Another reason why I encourage everyone to use TypeScript is the fact that the types are being declared outside of the machine definition, making the machine code easy to read even for people without extensive TypeScript knowledge. I made a conscious decision to use TypeScript in most posts throughout the series and you'll find that when we got to implement the machines, all we need to do is to pass the context type, the state schema, and possible events to the Machine factory function. From that point, we don't have to worry about the types anymore.

const gameMachine = Machine<GameContext, GameStateSchema, GameEvent>({
  /**
   * Almost no types will be found in here
   */
})
Enter fullscreen mode Exit fullscreen mode

2. UI is a function of state, make it explicit!

Without statecharts, our business logic is spread throughout the application and states are a fuzzy mess of interdependent booleans.

If we were to render todos in an app that doesn't use deterministic states, the code might look like the following.

{ !isLoading && !hasErrors && todos.length > 0 && (
  <ul>
    {todos.map((todo, index) => <li key={index}>{todo}</li>)}
  </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

Going to state machines with a state structure like the following

interface TodoStateSchema {
  states: {
    idle: {};
    loading: {};
    error: {};
    hasLoaded: {};
  }
}

interface TodoContext {
  todos: string[];
}
Enter fullscreen mode Exit fullscreen mode

We might be tempted to refactor our code from above to something like the one below.

{ state.matches('hasLoaded') && state.context.todos.length > 0 && (
  <ul>
    {todos.map((todo, index) => <li key={index}>{todo}</li>)}
  </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

As we can see, we have eliminated the boolean variables and got rid of impossible states in the process (e.g isLoading and hasError being true at the same time). However, I want to point out that at times it can be better to distinctively express UI states with declarative state nodes.

We can move the conditional logic from our component to state machines by adding deeper state nodes,

interface TodoStateSchema {
  states: {
    idle: {};
    loading: {};
    error: {};
    hasLoaded: {
      states: {
        noTodos: {};
        todos: {};
      };
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

or by refactoring to an orthogonal state structure (request and has are parallel state nodes).

interface TodoStateSchema {
  states: {
    request: {
      states: {
        idle: {};
        loading: {};
        error: {};
        hasLoaded: {};
      };
    };
    has: {
      states: {
        noTodos: {};
        todos: {};
      };
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Then we can determine the state of the machine like so:

{ state.matches({has: 'todos'}) && (
  <ul>
    {todos.map((todo, index) => <li key={index}>{todo}</li>)}
  </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

Using typestates which we didn't get to cover in the series, one can even enforce the condition that the machine must always have a non-empty array of todos inside the context before transitioning into the has.todos state.

The takeaway from this is to not be afraid to express your UI with distinct state nodes. When doing so, also don't be discouraged if certain state nodes sound weird in isolation. This is completely normal and happens usually with state nodes higher in the hierarchy (e.g has). The leaf state nodes or the combination of parent-child nodes are the ones that count.

Generally speaking, the more conditional logic you can move into your machine, the fewer bugs your application will have.

3. Visual Studio Code tooling

If you are using anything other than Visual Studio Code, feel free to add the extension name or config option of the editor you are using into the comments

The first thing you'd want to install is an extension that colors your brackets. Since most of our logic is defined within the JSON machine definition, we'd want to make sure that besides indentation, a visual clue can help us to maneuver between state nodes, events, guards and any other code we put into our machines. I'm using the Bracket Pair Colorizer 2 extension but have seen that some people experienced some performance issues when installing it in VSCode. Should you get hit by a significant performance penalty, try another extension that does the same thing and let us know.

Secondly, there is a command to jump the cursor from a closing bracket to the matching opening one and vice versa. This has saved me hours in finding the end of my state nodes and events. Below, you can see the default keybinding for the editor.action.jumpToBracket command. Feel free to bind it to a key that you can reach more easily. I personally settled on F3.

{
  "key": "ctrl+m",
  "command": "editor.action.jumpToBracket",
  "when": "editorFocus"
}
Enter fullscreen mode Exit fullscreen mode

4. Chrome extension

Install the XState DevTools extension by @amitnovick and ensure to enable the visualization for your machines.

const [state, send] = useMachine(someMachine, { devTools: true})

5. Prototype using the visualizer

Always start with defining the state structure of your statecharts. Think about what kind of responsibility every machine should have and how you could wire them to other machines using the actor model. I found that it is always a good idea to start modeling on paper and have recently bought a whiteboard for the same reason. When you go to the prototyping phase, use the visualizer that is also being used in the chrome extension to ensure you are not missing any transitions or states. Visual debugging is so good, you'll never want to go back to code that can't be visualized.

My workflow of writing a new state machine/statechart mostly follows the following steps:

  1. Brainstorming about possible states
  2. Define state schema in TypeScript
  3. Implement blueprint of machines with states and possible transitions
  4. Visualize and iterate over 1-3
  5. Implement machines and wire them together with other existing actors
  6. Wire the machine to our UI

6. Consume resources

Throughout the past 24 days, we've learned a lot of XState concepts and despite my attempt to explain multiple concepts on any given day, we didn't get to cover every feature of XState. In particular, model-based testing, more actor communication and activities are things that I haven't written about. I highly encourage you to read through the whole documentation start to finish to get a firm grasp on what is feasible with statecharts.

I haven't explored everything XState has to offer (e.g model-based testing) just yet. Once I do, I'd love to blog about it as I had a lot of fun writing the posts for this series.

Here are some of the best resources to learn more about statecharts and state machines:

  • David Khourshid known as @DavidKPiano on social media is the creator of XState. I'm very thankful for his relentless work on XState and believe it will have the greatest positive impact on the future of web apps. Because of this and the fact that I've gotten a much better developer by watching his public talks and the keyframer videocast, he is one of the people I look up to the most.
  • "World of Statecharts" wiki
  • Spectrum community

This is not an exhaustive list. Is there anything you think I should add? Let me know in the comments.

Thank you for reading the state machine/statechart series. I would love your feedback on Twitter or Telegram (@codingdive) as they were the first 25 blog posts I've ever written.

I'm currently developing a collaborative learning and tutoring platform and want to launch it soon. Please help me design the perfect platform for you by participating in this tiny anonymous survey. https://skillfountain.app/survey

Top comments (8)

Collapse
 
davidkpiano profile image
David K. 🎹

Thank you Mikey for all of your amazing, concise posts on statecharts! Merry Christmas and happy holidays!

Collapse
 
codingdive profile image
Mikey Stengel

Thank you David. Merry Christmas and happy holidays to you too! 😊

Collapse
 
gcsalemao7 profile image
Gustavo Carvalho

Hi Mikey, I really liked your article. Finally, a place where the use of state machines is clearly explained.

Do you have an opensource example of an application using Xstate + React? I'm developing an application in React and I still have some doubts about the development standards using Xstate.

For example, I have a General application stage machine that carries general application data and that state machine is responsible for calling the submachines of the other components. However, I am having difficulty managing the application states because I have to make the machines communicate. I would like to see an example of a complete application that has error management, socket and submachines.

Collapse
 
codingdive profile image
Mikey Stengel

Thank you, glad you enjoyed the series.
For examples of child machines, I'd recommend reading the blog posts from day 21 to day 23 with day 22 being the most important one as I explain actor communication in detail.
Error management can be implemented in a variety of ways but what I usually do is to add a dedicated error state that upon entering writes an errorMessage into context.

error: {
  entry: assign({errorMessage: (context, event) => 'There was an error. Here is why.'+ event.data
// using `on: { /* events:  */ }`, we could eithet add a "RETRY" event or immediately transition to another node using a transient transition like this `"": { target: "stateAfterError"}`
}

Likewise, instead of assigning a context, you could also notify a parent machine using sendParent.
Even better, for exception handling with actors, you can also use the new escalate action creator which delegates errors to parent machines. It was just introduced in version 4.7 of XState which is probably why there aren't too many examples of it yet. xstate.js.org/docs/guides/actions....

Unfortunately, I'm also not aware of any example that uses sockets but would love to see one myself. You could implement streams/sockets by invoking callbacks as a service.

Collapse
 
gcsalemao7 profile image
Gustavo Carvalho

Hello Mikey I using XStateDevTools but the issue is that the error below is generated. Note: I'm not using TypeScript.

Have you been through this?

Uncaught (in promise) TypeError: Converting circular structure to JSON
--> starting at object with constructor 'Object'
| property 'done.invoke.service-heatmap-id' -> object with constructor 'Array'
| index 0 -> object with constructor 'Object'
| property 'source' -> object with constructor 'Object'
--- property 'on' closes the circle
at JSON.stringify ()
at Object.send (injected.js:41)
at Interpreter.update (interpreter.js:252)
at interpreter.js:127
at Scheduler.process (scheduler.js:64)
at Scheduler.flushEvents (scheduler.js:55)
at Scheduler.schedule (scheduler.js:44)
at Interpreter.send (interpreter.js:121)
at actor.id (interpreter.js:945)

Collapse
 
codingdive profile image
Mikey Stengel

I've not had this error before. Could you share some code?
If I had to guess, you are referencing (parts of) your machine inside the machine, creating a circular structure. The only place where I can see that happening is when you use object getters. xstate.js.org/docs/guides/ids.html...

Collapse
 
tokerranta profile image
Markus R

Thank you Mikey, amazing work!

Collapse
 
codingdive profile image
Mikey Stengel

Thanks Markus.
If you are ever stuck with a state machine or want feedback, feel free to send them my way :)