braindump

State Machine Tip: Share a global machine in React using Context

TLDR: Check the code here and here

Here you can see a way to create a XState Actor and make it accessible in React using the Context API. The cool thing about doing this, is that the actor value will never change, making it a safe to add it as a value of our Context and avoid unnecesary renders or rendering all your tree when the state changes.

  1. Create your global state machine
import { createModel } from "xstate/lib/model"
type User = {
  username: string
  id: string
  email: string
}
export const authModel = createModel(
  {
    user: null as User | null,
  },
  {
    events: {
      LOG_IN: () => ({}),
      LOG_OUT: () => ({}),
    },
  }
)

export const authMachine = authModel.createMachine({
  context: {
    user: {
      // this can be fetched inside the machine.
      // checkout my other post:
      // https://www.horacioh.com/writing/auth-flow-with-xstate-and-react
      email: "foo@bar.com",
      username: "horacio",
      id: "1234567890",
    },
  },
  // ...
})
  1. create a Provider Component and a custom hook for your actor
// ...

const authContext = React.createContext<InterpreterFrom<
  typeof authMachine
> | null>(null)

export function useAuth() {
  const context = React.useContext(authContext)
  if (!context) {
    throw new Error(`useAuth must be called inside a AuthProvider`)
  }

  return context
}

export const AuthProvider = authContext.Provider
  1. You can also create custom selectors for accessing the state or context values:
import { useSelector } from "@xstate/react"

// ...

export function useUser() {
  return useSelector(useAuth(), (state) => state.context.user)
}
  1. Implement and Start your Actor
import { AuthProvider } from "./auth"
export function App() {
  const authService = useInterpret(authMachine)
  // ...
  return <AuthProvider value={authService}>{/* ... */}</AuthProvider>
}
  1. Inside any component that is rendered inside the Provider, you can then access the auth actor:
import { useAuth } from "./auth"
import { useActor } from "@xstate/react"

export function Topbar() {
  const authService = useAuth()
  const [state, send] = useActor(authService)

  // ...
}

BONUS

  1. You can abstract all the Context/Provider creation to a simple set of utility functions! (kudos to the Stately Team!)
// machine-utils.ts

import { useSelector } from "@xstate/react"
import * as React from "react"
import { ActionTypes, Interpreter } from "xstate"

export function isNullEvent(eventName: string) {
  return eventName == ActionTypes.NullEvent
}

export function isInternalEvent(eventName: string) {
  const allEventsExceptNull = Object.values(ActionTypes).filter(
    (val) => !isNullEvent(val)
  )
  return allEventsExceptNull.some((prefix) => eventName.startsWith(prefix))
}

export function createInterpreterContext<
  TInterpreter extends Interpreter<any, any, any>
>(displayName: string) {
  const [Provider, useContext] =
    createRequiredContext<TInterpreter>(displayName)

  const createUseSelector =
    <Data>(selector: (state: TInterpreter["state"]) => Data) =>
    () => {
      return useSelector(useContext(), selector)
    }

  return [Provider, useContext, createUseSelector] as const
}

export function createRequiredContext<TContext>(displayName: string) {
  const context = React.createContext<TContext | null>(null)
  context.displayName = displayName
  function useContext() {
    const ctx = React.useContext(context)
    if (!ctx) {
      throw new Error(
        `use${displayName} must be called inside a ${displayName}Provider`
      )
    }

    return ctx
  }

  return [context.Provider, useContext] as const
}

And use them like this:

// auth-context.ts

import { authStateMachine } from "./auth"
import { InterpreterFrom } from "xstate"
import { createInterpreterContext } from "./machine-utils"

const [AuthProvider, useAuth, createAuthSelector] =
  createInterpreterContext<InterpreterFrom<typeof authStateMachine>>("Auth")

export { AuthProvider, useAuth }

export const useUser = createAuthSelector((state) => state.context.user)

Feel free to checkout the code for the version 1 here and the version with the bonus code here

Version 1

Bonus Version