DEV Community

Cover image for "Just Use Props": An opinionated guide to React and XState
Matt Pocock
Matt Pocock

Posted on

"Just Use Props": An opinionated guide to React and XState

XState can feel overwhelming. Once you've gone through Kyle or David's courses and read through the docs, you'll get a thorough understanding of the API. You'll see that XState is the most powerful tool available for managing complex state.

The challenge comes when integrating XState with React. Where should state machines live in my React tree? How should I manage parent and child machines?

Just Use Props

I'd like to propose an architecture for XState and React which prioritises simplicity, readability and type-safety. It's incrementally adoptable, and gives you a base for exploring more complex solutions. We've used it at Yozobi in production, and we're planning to use it for every project moving forward.

It's called just use props. It's got a few simple rules:

  1. Create machines. Not too many. Mostly useMachine
  2. Let React handle the tree
  3. Keep state as local as possible

Create machines. Not too many. Mostly useMachine

The simplest way to integrate a state machine in your app is with useMachine.

import { createMachine, interpret } from 'xstate';
import { useMachine } from '@xstate/react';

const machine = createMachine({
  initial: 'open',
  states: {
    open: {},
    closed: {},
  },
});

const Component = () => {
  const [state, send] = useMachine(machine);

  return state.matches('open') ? 'Open' : 'Closed';
};
Enter fullscreen mode Exit fullscreen mode

Note that this puts React in charge of the machine. The machine is tied to the component, and it obeys all the normal React rules of the data flowing down. In other words, you can think of it just like useState or useReducer, but a vastly improved version.

Let React handle the tree

Let's say you have a parent component and a child component. The parent has some state which it needs to pass to the child. There are several ways to do this.

Passing services through props

The first is to pass a running service to the child which the child can subscribe to:

import { useMachine, useService } from '@xstate/react';
import { createMachine, Interpreter } from 'xstate';

/**
 * Types for the machine declaration
 */
type MachineContext = {};
type MachineEvent = { type: 'TOGGLE' };

const machine = createMachine<MachineContext, MachineEvent>({});

const ParentComponent = () => {
  /**
   * We instantiate the service here...
   */
  const [state, send, service] = useMachine(machine);

  return <ChildComponent service={service} />;
};

interface ChildComponentProps {
  service: Interpreter<MachineContext, any, MachineEvent>;
}

const ChildComponent = (props: ChildComponentProps) => {
  /**
   * ...and receive it here
   */
  const [state, send] = useService(props.service);

  return (
    <button onClick={() => send('TOGGLE')}>
      {state.matches('open') ? 'Open' : 'Closed'}
    </button>
  );
};
Enter fullscreen mode Exit fullscreen mode

I don't like this pattern. For someone not used to XState, it's unclear what a 'service' is. We don't get clarity from reading the types, which is a particularly ugly Interpreter with multiple generics.

The machine appears to bleed across multiple components. Its service seems to have a life of its own, outside of React's tree. To a newbie, this feels like misdirection.

Just pass props

This can be expressed much more cleanly using props:

import { useMachine } from '@xstate/react';
import { createMachine } from 'xstate';

/**
 * Types for the machine declaration
 */
type MachineContext = {};
type MachineEvent = { type: 'TOGGLE' };

const machine = createMachine<MachineContext, MachineEvent>({});

const ParentComponent = () => {
  const [state, send] = useMachine(machine);

  return (
    <ChildComponent
      isOpen={state.matches('open')}
      toggle={() => send('TOGGLE')}
    />
  );
};

/**
 * Note that the props declarations are
 * much more specific
 */
interface ChildComponentProps {
  isOpen: boolean;
  toggle: () => void;
}

const ChildComponent = (props: ChildComponentProps) => {
  return (
    <button onClick={() => props.toggle()}>
      {props.isOpen ? 'Open' : 'Closed'}
    </button>
  );
};
Enter fullscreen mode Exit fullscreen mode

Much better. We get several improvements in clarity in the ChildComponent - the types are much easier to read. We get to ditch the use of Interpreter and useService entirely.

The best improvement, though, is in the ParentComponent. In the previous example, the machine crossed multiple components by passing its service around. In this example, it's scoped to the component, and props are derived from its state. This is far easier to grok for someone unused to XState.

Keep state as local as possible

Unlike tools which require a global store, XState has no opinion on where you keep your state. If you have a piece of state which belongs near the root of your app, you can use React Context to make it globally available:

import React, { createContext } from 'react';
import { useMachine } from '@xstate/react';
import { createMachine } from 'xstate';

const globalMachine = createMachine({});

interface GlobalContextType {
  isOpen: boolean;
  toggle: () => void;
}

export const GlobalContext = createContext<GlobalContextType>();

const Provider: React.FC = ({ children }) => {
  const [state, send] = useMachine(globalMachine);

  return (
    <GlobalContext.Provider
      value={{ isOpen: state.matches('open'), toggle: () => send('TOGGLE') }}
    >
      {children}
    </GlobalContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Just as above, we're not passing a service, but props, into context.

If you have a piece of state which needs to belong lower in your tree, then obey the usual rules by lifting state up to where it's needed.

If that feels familiar, you're right. You're making the same decisions you're used to: where to store state and how to pass it around.

Examples and challenges

Syncing parents and children

Sometimes, you need to use a parent machine and a child machine. Let's say that you need the child to pay attention to when a prop changes from the parent - for instance to sync some data. Here's how you can do it:

const machine = createMachine({
  initial: 'open',
  context: {
    numberToStore: 0,
  },
  on: {
    /**
     * When REPORT_NEW_NUMBER occurs, sync
     * the new number to context
     */
    REPORT_NEW_NUMBER: {
      actions: [
        assign((context, event) => {
          return {
            numberToStore: event.newNumber,
          };
        }),
      ],
    },
  },
});

interface ChildComponentProps {
  someNumber: number;
}

const ChildComponent = (props: ChildComponentProps) => {
  const [state, send] = useMachine(machine);

  useEffect(() => {
    send({
      type: 'REPORT_NEW_NUMBER',
      newNumber: props.someNumber,
    });
  }, [props.someNumber]);
};
Enter fullscreen mode Exit fullscreen mode

This can also be used to sync data from other sources, such as query hooks:

const ChildComponent = () => {
  const [result] = useSomeDataHook(() => fetchNumber());

  const [state, send] = useMachine(machine);

  useEffect(() => {
    send({
      type: 'REPORT_NEW_NUMBER',
      newNumber: result.data.someNumber,
    });
  }, [result.data.someNumber]);
};
Enter fullscreen mode Exit fullscreen mode

Summary

In the "just use props" approach, XState lets React take charge. We stick to idiomatic React by passing props, not services. We keep machines scoped to components. And we put state at the level it's needed, just like you're used to.

This article isn't finished. I'm sure there will be many more questions about integrating XState with React. My plan is to come back to this article again with more examples and clarifications. Thanks for your time, and I'm looking forward to seeing what you build with XState.

Discussion (9)

Collapse
skona27 profile image
Jakub Skoneczny

I just really like the part where you make an abstraction and don't pass send directly:

<ChildComponent
    isOpen={state.matches('open')}
    toggle={() => send('TOGGLE')}
/>
Enter fullscreen mode Exit fullscreen mode

It is so much easier to make any future refactors because your child components don't know anything about the XState - they get values to render and functions to run as event handlers.

Good work, and keep spreading the XState knowledge! IMO it will become the most popular library for any state management soon 🙂

Collapse
redbar0n profile image
Magne • Edited

Couldn't you just use the same machine in both the parent and the child component? Since state machines are orthogonal to the component (rendering) hierarchy then they don't have to conform to it. (Presuming here that the useMachine hook will cause a re-render, of course.)

Like this:

import { useMachine } from '@xstate/react';
import { createMachine } from 'xstate';

/**
 * Types for the machine declaration
 */
type MachineContext = {};
type MachineEvent = { type: 'TOGGLE' };

const machine = createMachine<MachineContext, MachineEvent>({});

const ParentComponent = () => {
  const [state, send] = useMachine(machine);
  // ... some extra code that warrants using this macine in this component ...
  return (
    <ChildComponent />
  );
};

const ChildComponent = () => {
  const [state, send] = useMachine(machine);
  return (
    <button onClick={() => send('TOGGLE')}>
      {state.matches('open') ? 'Open' : 'Closed'}
    </button>
  );
};
Enter fullscreen mode Exit fullscreen mode

Thus, state could be stored in the relevant component, but accessed from any other component (not just a child), without having to conform to passing props up and down the render tree. You'd also be utilising the full capacity of hooks:

Hooks allow you to reuse stateful logic without changing your component hierarchy. This makes it easy to share Hooks among many components or with the community.

reactjs.org/docs/hooks-intro.html

Collapse
mpocock1 profile image
Matt Pocock Author

No - state is not shared between the two machines.

This is like calling useReducer twice, using the same reducer.

Collapse
redbar0n profile image
Magne • Edited

Ah, ok, I see that useMachine actually creates a new service...

Is there another way of having the state independent of the React tree (so one does not even have to pass props)? (While also having React re-render the components where the state is used when it changes).

Maybe putting the const [state, send] = useMachine(machine); into a shared hook that is used inside the relevant components?

Or extract that line outside of any component and then referencing state and send inside components (as a closure)?

Thread Thread
redbar0n profile image
Magne

Seems like using React's Context is the recommended approach: github.com/statelyai/xstate/discus...

Thread Thread
redbar0n profile image
Magne

This is the closest to what I was originally thinking: github.com/statelyai/xstate/discus...

Collapse
fraser_drops profile image
Fraser

Integrating XState with React is something I've thought about a lot, so thanks for putting this together and sharing how you go about it!

It seems like your approach is React plus a bit of XState to replace local state where it's needed. The advantage is that you can write React components and make use of props as normal when using React.

I can see how this approach is a great first step for introducing XState into a codebase, but it seems like it leaves a lot of the power of XState on the table. It's like the 'Actor' part of XState is not really used. For example, because your XState machines only communicate via React, I don't think the Inspector sequence diagram would show events being sent between machines?

Quite related to this, I wonder how you deal with global state with this approach. With XState replacing a useState or a useReducer, do you turn to anything like Context for global state? It seems like XState could work really well to orchestrate state at a global level, not just a component level. Do you have any experience of attempting this?

Collapse
mpocock1 profile image
Matt Pocock Author

it leaves a lot of the power of XState on the table

True! But with great power comes great responsibility. Sticking within the guidelines above is a great first step for introducing XState to a codebase.

Context for global state

Yes! This is used in the example above. XState is an amazing tool for orchestrating global state (some of the most important state in your app) because it is so robust. I've used this pattern a dozen times or so in production code.

Collapse
fraser_drops profile image
Fraser

Sticking within the guidelines above is a great first step for introducing XState to a codebase

I totally agree!

This is used in the example above. XState is an amazing tool for orchestrating global state

Yup for sure, I'm looking forward to it becoming more widely used for this. Thanks for your work!