DEV Community

loading...
Cover image for "Just Use Props": An opinionated guide to React and XState

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

mpocock1 profile image Matt Pocock ・4 min read

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

pic
Editor guide
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
fmcintosh 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
fmcintosh 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!