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:
- Create machines. Not too many. Mostly useMachine
- Let React handle the tree
- 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';
};
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>
);
};
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>
);
};
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>
);
};
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]);
};
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]);
};
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.
Top comments (9)
I just really like the part where you make an abstraction and don't pass
send
directly: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 🙂
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:
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:
reactjs.org/docs/hooks-intro.html
No - state is not shared between the two machines.
This is like calling useReducer twice, using the same reducer.
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
andsend
inside components (as a closure)?Seems like using React's Context is the recommended approach: github.com/statelyai/xstate/discus...
This is the closest to what I was originally thinking: github.com/statelyai/xstate/discus...
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?
True! But with great power comes great responsibility. Sticking within the guidelines above is a great first step for introducing XState to a codebase.
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.
I totally agree!
Yup for sure, I'm looking forward to it becoming more widely used for this. Thanks for your work!