Skip to article
detaID ↗
← Back home

Build a State Machine with XState and Use It Across React Components (TypeScript)

Learn how to build a state machine with XState and use it across React components in TypeScript.

3 min read
#XState#React#TypeScript#State Management

When building forms or multi-step interactions in React, state can quickly get out of control. xstate helps by organizing your logic into finite states, actions, and transitions. In this guide, you’ll learn how to:

  • Model form logic using XState
  • Use createActorContext for a global-like provider pattern
  • Share state across components inside the provider

We’ll build a typed machine that handles editing a simple recipe object with ingredients.


1. Setup

bash
npm install xstate @xstate/react

2. Create the State Machine

Let’s model a "recipe editor" state machine.

ts
// recipeMachine.ts import { assign, setup } from "xstate"; type Ingredient = { name: string; amount: string; }; type Recipe = { id: string; title: string; ingredients: Ingredient[]; }; type RecipeEvent = | { type: "CHANGE_TITLE"; title: string } | { type: "ADD_INGREDIENT" } | { type: "REMOVE_INGREDIENT"; index: number } | { type: "EDIT_INGREDIENT"; index: number; ingredient: Ingredient } | { type: "SAVE_RECIPE" }; const defaultRecipe: Recipe = { id: crypto.randomUUID(), title: "", ingredients: [], }; const setupMachine = setup({ types: { context: {} as Recipe, events: {} as RecipeEvent, }, actions: { changeTitle: assign({ title: ({ event }) => event.type === "CHANGE_TITLE" ? event.title : undefined, }), addIngredient: assign({ ingredients: ({ context }) => [ ...context.ingredients, { name: "", amount: "" }, ], }), removeIngredient: assign({ ingredients: ({ context, event }) => event.type === "REMOVE_INGREDIENT" ? context.ingredients.filter((_, i) => i !== event.index) : context.ingredients, }), editIngredient: assign({ ingredients: ({ context, event }) => { if (event.type !== "EDIT_INGREDIENT") return context.ingredients; return context.ingredients.map((item, i) => i === event.index ? event.ingredient : item ); }, }), saveRecipe: ({ context }) => { console.log("Saved recipe:", context); }, }, }); export const recipeMachine = setupMachine.createMachine({ id: "recipeEditor", initial: "idle", context: ({ input }) => input ?? defaultRecipe, states: { idle: { on: { CHANGE_TITLE: { actions: "changeTitle" }, ADD_INGREDIENT: { actions: "addIngredient" }, REMOVE_INGREDIENT: { actions: "removeIngredient" }, EDIT_INGREDIENT: { actions: "editIngredient" }, SAVE_RECIPE: { actions: "saveRecipe" }, }, }, }, });

3. Create a Context Provider

We'll expose the machine using createActorContext.

ts
// RecipeMachineProvider.tsx import { createActorContext } from "@xstate/react"; import { recipeMachine } from "./recipeMachine"; export const RecipeMachineContext = createActorContext(recipeMachine);

4. Use the Machine in a Component Tree

tsx
// RecipeEditor.tsx import { RecipeMachineContext } from "./RecipeMachineProvider"; export const RecipeEditor = ({ initialRecipe }: { initialRecipe?: any }) => { return ( <RecipeMachineContext.Provider options={{ input: initialRecipe }}> <RecipeForm /> </RecipeMachineContext.Provider> ); };

5. Build the Form Component

tsx
// RecipeForm.tsx import { RecipeMachineContext } from "./RecipeMachineProvider"; export const RecipeForm = () => { const state = RecipeMachineContext.useSelector((s) => s.context); const actorRef = RecipeMachineContext.useActorRef(); return ( <div> <input value={state.title} placeholder="Recipe title" onChange={(e) => actorRef.send({ type: "CHANGE_TITLE", title: e.target.value }) } /> <h4>Ingredients</h4> {state.ingredients.map((ing, i) => ( <div key={i}> <input placeholder="Name" value={ing.name} onChange={(e) => actorRef.send({ type: "EDIT_INGREDIENT", index: i, ingredient: { ...ing, name: e.target.value, }, }) } /> <input placeholder="Amount" value={ing.amount} onChange={(e) => actorRef.send({ type: "EDIT_INGREDIENT", index: i, ingredient: { ...ing, amount: e.target.value, }, }) } /> <button onClick={() => actorRef.send({ type: "REMOVE_INGREDIENT", index: i }) } > Remove </button> </div> ))} <button onClick={() => actorRef.send({ type: "ADD_INGREDIENT" })}> Add Ingredient </button> <button onClick={() => actorRef.send({ type: "SAVE_RECIPE" })}> Save </button> </div> ); };

Summary

You now have a full xstate setup using:

✅ A typed state machine
✅ Context sharing via createActorContext
✅ A clean way to reuse logic across multiple components

🔗 Official docs if you want to go deeper: XState Documentation

More writing

A few more notes from my archive.

Running TypeScript 7 and ESLint Side by Side

How to use the native TypeScript 7 compiler for builds and type-checking while keeping TypeScript 6 available for the Compiler API required by ESLint.

Fixing the 'Ghost Changes' Bug in Bulk Edit Tables

Deep dive into debugging a tricky state management issue where the system detected changes that didn't actually exist, and how comparing with original values solved the problem.

Conditional Rendering: Keep It Inside the Component

Best practice for placing conditional rendering logic within React components using context or internal state.

Managing ClassName with clsx in React

Learn how to use clsx to manage className dynamically in React more cleanly and efficiently.

Leave a comment