DEV Community

jasonching
jasonching

Posted on

How to Manage the XState's State Scope in React.JS

XState is just so great. I am really amazed to see such a mathematical way of organizing state flow can happen in the Javascript world.

Here I want to explain how the state of this State Machine is managed in XState. This is something I don't find a clear explanation for in the official doc. I hope this article can clear some doubt for you.

A Simple Example

I have build a very simple on/off toggle button using a XState.

You can find the source code here.

toggleMachine.ts

import { assign, createMachine } from "xstate";

interface onOffMachineType {
  count: number;
}

const machine = createMachine<onOffMachineType>(
  {
    predictableActionArguments: true,
    id: "onOff",
    initial: "btnOff",
    context: {
      count: 0,
    },
    states: {
      btnOff: {
        on: {
          PRESS: {
            target: "btnOn",
            actions: "increment",
          },
        },
      },
      btnOn: {
        on: {
          PRESS: {
            target: "btnOff",
            actions: "increment",
          },
        },
      },
    },
  },
  {
    actions: {
      increment: () => {
        assign({
          count: (context: onOffMachineType) => context.count + 1,
        });
      },
    },
  }
);

export default machine;
Enter fullscreen mode Exit fullscreen mode

This is a simple on/off switch state machine. It's doing 2 things:

  • toggle between on and off
  • increment the count every time

toggle.tsx

import React from "react";
import { useMachine } from '@xstate/react';
import toggleMachine from "../machines/toggleMachine";

const Toggle = function() {
  const [current, send] = useMachine(toggleMachine);

  return (
    <div>
      <button onClick={() => send("PRESS") }>Click</button>
      <span style={{ marginLeft: 20 }}>{current.matches("btnOff") ? "Off" : "On"}</span>
      <span style={{ marginLeft: 20 }}>{current.context.count}</span>
    </div>) ;
}

export default Toggle;
Enter fullscreen mode Exit fullscreen mode

app.tsx

import "./styles.css";
import Toggle from "./components/toggle";

export default function App() {
  return (
    <div className="App">
      <Toggle />
      <Toggle />
      <Toggle />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now you have 3 Toggle components on the page, and each of them have independent states (the on/off state, and the total count number).

Let's clarify a few concepts here.

The Machine

The State Machine is created by invoking createMachine(config). This machine is just a logical blueprint without any state and transition happening.

What? The state machine has no state? So how can a Finite State Machine has no state?

Look at this sample code that is documented in the official site:

const { initialState } = promiseMachine;

console.log(initialState.value);
// => 'pending'

const nextState = promiseMachine.transition(initialState, { type: 'RESOLVE' });

console.log(nextState.value);
// => 'resolved'
Enter fullscreen mode Exit fullscreen mode

The answer is here. The machine is a pure function!

Pure function is a function that:

  • has no side effect
  • the output is purely depends on the input

About Functional Programming

If you have difficulty understanding about this concept, I would suggest you to learn more about functional programming. This is an interesting topic and would definitely improve your development skills a lot.

The State Is Saved Outside Of The Machine Object

This is a brilliant design. Separating these 2 gives lots of flexibility. For example:

  • You can have multiple states working on the same machine instance.
  • You can serialize and persist your state somewhere else, and use it back anytime later.
  • Keeping the machine stateless is easier to do test.

The Service

Service is an interpreted machine. That means it execute instructions of the machine. In order to do it, it needs to have:

  • the machine blueprint
  • current state (including context)
  • functions to perform operations, such as transition.

It is basically just a helper object to work with the machine. It's not necessary to use it, but it would make your work a lot easier.

In React, more importantly, useMachine() will return you a service that its life cycle is hooked up with your component.

This is what mentioned in the official doc.

A React hook (opens new window)that interprets the given machine and starts a service that runs for the lifetime of the component.

But wait... how do we get the service instance?

const [current, send] = useMachine(toggleMachine);
Enter fullscreen mode Exit fullscreen mode

The current is just the state object, and the send is just a send function. Where is the Service that we are talking about?

It's the third return arguments that you don't see often in the doc:

const [current, send, service] = useMachine(toggleMachine);

// Be aware that although they both have the same value, current is observable, while serivce.state is not.
const sameCurrent = service.state;
const sameSend = service.send;
Enter fullscreen mode Exit fullscreen mode

The first 2 arguments that useMachine() returns are actually just convenient objects. The are just the subset of the service object.

The other way to do is use useInterpret():

const service = useInterpret(toggleMachine);
Enter fullscreen mode Exit fullscreen mode

Without clear understanding about this, one of the mistake you can make would be:

const [current, send, service1] = useMachine(toggleMachine);
const service2 = useInterpret(toggleMachine);

// service1 and service2 have independent states.  They are different!
const notTheSameCurrent = service2.state;
const notTheSameSend = service2.send;
Enter fullscreen mode Exit fullscreen mode

Breaking Down Into Small Components

Now, lets see how we break down the on/off toggle example into small stateless components:

import React from "react";
import { useMachine } from '@xstate/react';
import toggleMachine from "../machines/toggleMachine";
import { InterpreterFrom } from "xstate";

interface ServiceProps {
  service: InterpreterFrom<typeof toggleMachine>;
}

// All these 3 child components now become stateless.  Their output is purely depended on the input (the input state).
// This makes them simple, easy to understand, and easy to be tested.
const Btn = function({service}: ServiceProps) {
  return <button onClick={() => service.send("PRESS") }>Click</button>;
}

const ShowState = function({service}: ServiceProps) {
  return <span style={{ marginLeft: 20 }}>{service.state.matches("btnOff") ? "Off" : "On"}</span>;
}

const ShowCount = function({service}: ServiceProps) {
  return <span style={{ marginLeft: 20 }}>{service.state.context.count}</span>;
}

const Toggle = function() {
  const [, , service] = useMachine(toggleMachine);

  // we pass in the service props to the child components
  // one of the common mistakes is to use useMachine(toggleMachine) inside child components.  That will just create a new service instance with new state instances instead of using the same one.
  return (
    <div>
      <Btn service={service} />
      <ShowState service={service} />
      <ShowCount service={service} />
    </div>) ;
}

export default Toggle;
Enter fullscreen mode Exit fullscreen mode

Global State

Now let's explore about how to make your state being accessible in global scope.

When To Use Global State?

Putting everything into global is tempting because things can be easily accessed by all components.

But this will make all your components stateful. (I have already explained why our design should toward stateless instead of stateful above.)

We should keep our states as local as possible. General things that you should put into global state are:

  • Fetched data that being used everywhere
  • User's profile data
  • User's authorization data
  • Theme apperance

Things that you should put into local state are:

  • Transistent UI state
  • Form filling status

Using React Context

This sample is very similar to the one in official doc.

The code is here.

App.tsx

import React, { createContext } from "react";
import toggleMachine from "./machines/toggleMachine";
import { useMachine } from "@xstate/react";
import "./styles.css";
import { InterpreterFrom } from "xstate";
import Toggle from "./components/toggle";

export type GlobalContextType = {
  toggleService: InterpreterFrom<typeof toggleMachine>;
};

// The GlobalContext is created out of the App scope, and it is exported to be share with all other components
export const GlobalContext = createContext<GlobalContextType>(null);

export default function App() {
  // the official doc is using useInterpret to get the service
  // I keep using useMachine just to make it consistence with other examples I was doing
  // They both give you the service instance anyway
  // This toggleService have the same life cycle as the App component (that's our whole applciaiton)
  const [, , toggleService] = useMachine(toggleMachine);

  return (
    // The service instance is assigned into the GlobalContext here
    <GlobalContext.Provider value={{ toggleService }}>
      <div className="App">
        {/* Now 3 Toggle components are sharing the same state */}
        <Toggle />
        <Toggle />
        <Toggle />
      </div>
    </GlobalContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

toggle.tsx

import React, { useContext } from "react";
import { GlobalContext, GlobalContextType } from "../App";
import { useActor } from "@xstate/react";

const Toggle = function() {
  const { toggleService } = useContext<GlobalContextType>(GlobalContext);
  // useActor() will make your state reactive.
  const [state] = useActor(toggleService);

  return (
    <div>
      <button onClick={() => toggleService.send("PRESS") }>Click</button>
      <span style={{ marginLeft: 20 }}>{state.matches("btnOff") ? "Off" : "On"}</span>
      <span style={{ marginLeft: 20 }}>{state.context.count}</span>
    </div>) ;
}

export default Toggle;
Enter fullscreen mode Exit fullscreen mode

Putting States into Mobx

Can't we just keep the state in XState? Why do we still need Mobx or Redux?

XState is good at managing state flow. State management tools is for managing global state and keep your states reactive in React. They are not direct replacement.

It is completely fine to keep your states in XState, but you may find it useful to introduce state management tools when the project gets bigger.

Below example is using Mobx. It should be very simple to switch to Redux.

The idea is that, we listen to the transition events in XState, and apply the state changes to the Mobx store.

That's it. It's very easy to understand, but putting them all together requires a bit of work. Especially about how you setup the context and create the store.

The code is here.

App.tsx

import createToggleStore, { ToggleStoreContext } from "./stores/toggleStore";
import toggleMachine from "./machines/toggleMachine";
import { useMachine } from "@xstate/react";
import Toggle from "./components/toggle";
import "./styles.css";

export default function App() {
  const [, , toggleService] = useMachine(toggleMachine);

  // The Mobx store is created, and the PRESS function of this particular service instance is passed in.
  const toggleStore = createToggleStore(() => {
    toggleService.send("PRESS");
  });

  // This is where we sync the state to Mobx
  // We listen to the transition events, and put the latest states back to Mobx
  // We can do the same for Redux here
  toggleService.onTransition((state) => {
    if (state.changed) {
      toggleStore.setCount(state.context.count);
      toggleStore.setState(state.value.toString());
    }
  });

  return (
    <ToggleStoreContext.Provider value={toggleStore}>
      <div className="App">
        {/* All three Toggle components are watching the same state from Mobx */}
        <Toggle />
        <Toggle />
        <Toggle />
      </div>
    </ToggleStoreContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

toggleStore.ts

import { makeAutoObservable } from "mobx";
import { createContext } from "react";

export type ToggleStoreType = {
  count: number;
  state: string;
  setCount(n: number): void;
  setState(s: string): void;
  press: () => void;
};

// We need to pass in the transition function because we don't have this before the XState's service instance is created in App.tsx
export default function createToggleStore(press: () => void) {
  return makeAutoObservable<ToggleStoreType>({
    count: 0,
    state: "",
    setCount(n: number) {
      this.count = n;
    },
    setState(s: string) {
      this.state = s;
    },
    press
  });
}

// I am keeping the Context here instead of App.tsx
export const ToggleStoreContext = createContext<ToggleStoreType>(null);
Enter fullscreen mode Exit fullscreen mode

toggle.tsx

import React, { useContext } from "react";
import { ToggleStoreContext, ToggleStoreType } from "../stores/toggleStore";
import { observer } from "mobx-react-lite";

const Toggle = function () {
  // we now get back our toggleStore here
  // As you can see, there is no XState dependency here
  // This component is only interacting with Mobx.
  const toggleStore = useContext<ToggleStoreType>(ToggleStoreContext);

  const View = observer(() => (
    <div>
      <button onClick={() => toggleStore.press()}>Click</button>
      <span style={{ marginLeft: 20 }}>
        {toggleStore.state === "btnOff" ? "Off" : "On"}
      </span>
      <span style={{ marginLeft: 20 }}>{toggleStore.count}</span>
    </div>
  ));

  return <View />;
};

export default Toggle;
Enter fullscreen mode Exit fullscreen mode

Conclusion

Here you go! We have done XState handling in 3 ways:

  • Under local component
  • Global based on React Context
  • Global with Mobx

Choosing which one to use is totally depends on the situtation. The most important is to understand how everything works together.

XState is a great tool to help you seperate your business logic out from your UI code, and it helps to make your components simple. I encourage all React developers to learn XState.

Top comments (0)