Many React applications follow the Flux architecture popularised by Redux. This setup can be characterised by a few key ideas:
- It uses a single object at the top of your app which stores all application state, often called the store.
- It provides a single
dispatch
function which can be used to send messages up to the store. Redux calls theseaction
s, but I'll be calling themevents
- as they're known in XState. - How the store responds to these messages from the app are expressed in pure functions - most often in reducers.
This article won't go into depth on whether the Flux architecture is a good idea. David Khourshid's article Redux is half a pattern goes into great detail here. For the purposes of this article, we're going to assume that you like having a global store, and you want to replicate it in XState.
There are many reasons for wanting to do so. XState is second-to-none when it comes to managing complex asynchronous behaviour and modelling difficult problems. Managing this in Redux apps usually involves middleware: either redux-thunk, redux-loop or redux-saga. Choosing XState gives you a first-class way to manage complexity.
A globally available store​
To mimic Redux's globally-available store, we're going to use React context. React context can be a tricky tool to work with - if you pass in values which change too often, in can result in re-renders all the way down the tree. That means we need to pass in values which change as little as possible.
Luckily, XState gives us a first-class way to do that.
import React, { createContext } from "react";
import { useInterpret } from "@xstate/react";
import { authMachine } from "./authMachine";
import { ActorRefFrom } from "xstate";
interface GlobalStateContextType {
authService: ActorRefFrom<typeof authMachine>;
}
export const GlobalStateContext = createContext(
// Typed this way to avoid TS errors,
// looks odd I know
{} as GlobalStateContextType
);
export const GlobalStateProvider = (props) => {
const authService = useInterpret(authMachine);
return (
<GlobalStateContext.Provider value={{ authService }}>
{props.children}
</GlobalStateContext.Provider>
);
};
Using useInterpret
returns a service
, which is a static reference to the running machine which can be subscribed to. This value never changes, so we don't need to worry about wasted re-renders.
Utilising context​
Further down the tree, you can subscribe to the service like this:
import React, { useContext } from "react";
import { GlobalStateContext } from "./globalState";
import { useActor } from "@xstate/react";
export const SomeComponent = (props) => {
const globalServices = useContext(GlobalStateContext);
const [state] = useActor(globalServices.authService);
return state.matches("loggedIn") ? "Logged In" : "Logged Out";
};
The useActor
hook listens for whenever the service changes, and updates the state
value.
Improving Performance​
There's an issue with the implementation above - this will update the component for any change to the service. Redux offers tools for deriving state using selectors - functions which restrict which parts of the state can result in components re-rendering.
Luckily, XState provides that too.
import React, { useContext } from "react";
import { GlobalStateContext } from "./globalState";
import { useSelector } from "@xstate/react";
const selector = (state) => {
return state.matches("loggedIn");
};
export const SomeComponent = (props) => {
const globalServices = useContext(GlobalStateContext);
const isLoggedIn = useSelector(globalServices.authService, selector);
return isLoggedIn ? "Logged In" : "Logged Out";
};
Now, this component will only re-render when state.matches('loggedIn')
returns a different value. This is my recommended approach over useActor
for when you want to optimise performance.
Dispatching events
For dispatching events to the global store, you can call a service's send function directly.
import React, { useContext } from "react";
import { GlobalStateContext } from "./globalState";
export const SomeComponent = (props) => {
const globalServices = useContext(GlobalStateContext);
return (
<button onClick={() => globalServices.authService.send("LOG_OUT")}>
Log Out
</button>
);
};
Note that you don't need to call useActor for this, it's available right on the context.
Deviations from Flux​
Keen-eyed readers may spot that this implementation is slightly different from Flux. For instance - instead of a single global store, one might have several running machines at once: authService
, dataCacheService
, and globalTimeoutService
. Each of them have their own send
attributes, too - so you're not calling a global dispatch.
These changes can be worked around. One could create a synthetic send inside the global store which called all the services' send
function manually. But personally, I prefer knowing exactly which services my messages are being passed to, and it avoids having to keep events globally namespaced.
Summary​
XState can work beautifully as a global store for a React application. It keeps application logic co-located, treats side effects as first-class citizens, and offers good performance with useSelector
. You should choose this approach if you're keen on the Flux architecture but feel your app's logic is getting out of hand.