Back to Blog

React State Machines in 2026: XState v5, useReducer Patterns

Model complex React UI flows with state machines: XState v5 actors, useReducer finite state patterns, multi-step wizards, form flow states, and testing state transitions.

Viprasol Tech Team
13 min read
Updated 2027

React State Machines in 2026: XState v5, useReducer Patterns, and Complex UI Flows

Quick answer. State machines fix UI bugs by making impossible states unrepresentable, replacing boolean soup like separate isLoading and hasError flags with explicit enumerated states. Use lightweight useReducer machines for simple flows and XState v5 for complex wizards needing parallel states, delayed transitions, and actor-based orchestration.

Most UI bugs live in state transitions — the user clicks "Submit", the button gets disabled, the spinner appears, then the request fails, but now the form is in an ambiguous half-submitted state with no way out. Finite state machines prevent this by making impossible states impossible to represent.

This post covers two approaches: lightweight useReducer state machines for simple flows, and XState v5 for complex multi-step workflows with parallel states, delayed transitions, and actor-based orchestration. We cover multi-step wizards, form submission flows, and testing state transitions.


The Core Insight: Enumerate Valid States

// ❌ Boolean soup — impossible states are possible
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
// Bug: isLoading=true AND isSuccess=true is representable but nonsensical

// ✅ Discriminated union — only valid states exist
type SubmitState =
  | { status: "idle" }
  | { status: "submitting" }
  | { status: "success"; data: OrderConfirmation }
  | { status: "error"; error: string };

// Can't be loading AND successful at the same time
const [state, setState] = useState<SubmitState>({ status: "idle" });

useReducer State Machine (Lightweight)

For simpler flows, a typed reducer with a finite state enum is sufficient:

// hooks/useCheckoutFlow.ts
type CheckoutState =
  | { step: "cart"; cartId: string }
  | { step: "shipping"; cartId: string; shippingAddress?: Address }
  | { step: "payment"; cartId: string; shippingAddress: Address }
  | { step: "review"; cartId: string; shippingAddress: Address; paymentMethod: PaymentMethod }
  | { step: "submitting"; cartId: string }
  | { step: "success"; orderId: string }
  | { step: "error"; previousStep: string; error: string };

type CheckoutEvent =
  | { type: "NEXT" }
  | { type: "BACK" }
  | { type: "SET_SHIPPING"; address: Address }
  | { type: "SET_PAYMENT"; method: PaymentMethod }
  | { type: "SUBMIT" }
  | { type: "SUCCESS"; orderId: string }
  | { type: "FAILURE"; error: string }
  | { type: "RETRY" };

function checkoutReducer(state: CheckoutState, event: CheckoutEvent): CheckoutState {
  switch (state.step) {
    case "cart":
      if (event.type === "NEXT") return { step: "shipping", cartId: state.cartId };
      return state;

    case "shipping":
      if (event.type === "SET_SHIPPING") return { ...state, shippingAddress: event.address };
      if (event.type === "NEXT" && state.shippingAddress) {
        return { step: "payment", cartId: state.cartId, shippingAddress: state.shippingAddress };
      }
      if (event.type === "BACK") return { step: "cart", cartId: state.cartId };
      return state;

    case "payment":
      if (event.type === "SET_PAYMENT") return { ...state, paymentMethod: event.method };
      if (event.type === "NEXT" && state.paymentMethod) {
        return {
          step: "review",
          cartId: state.cartId,
          shippingAddress: state.shippingAddress,
          paymentMethod: state.paymentMethod,
        };
      }
      if (event.type === "BACK") {
        return { step: "shipping", cartId: state.cartId, shippingAddress: state.shippingAddress };
      }
      return state;

    case "review":
      if (event.type === "SUBMIT") return { step: "submitting", cartId: state.cartId };
      if (event.type === "BACK") {
        return {
          step: "payment",
          cartId: state.cartId,
          shippingAddress: state.shippingAddress,
          paymentMethod: state.paymentMethod,
        };
      }
      return state;

    case "submitting":
      if (event.type === "SUCCESS") return { step: "success", orderId: event.orderId };
      if (event.type === "FAILURE") return { step: "error", previousStep: "review", error: event.error };
      return state;

    case "error":
      if (event.type === "RETRY") return { step: "review", ...reconstructReviewState(state) };
      return state;

    default:
      return state;
  }
}

export function useCheckoutFlow(cartId: string) {
  const [state, dispatch] = useReducer(checkoutReducer, {
    step: "cart",
    cartId,
  });

  const submit = useCallback(async () => {
    dispatch({ type: "SUBMIT" });
    try {
      const order = await createOrder(cartId);
      dispatch({ type: "SUCCESS", orderId: order.id });
    } catch (err) {
      dispatch({ type: "FAILURE", error: err instanceof Error ? err.message : "Unknown error" });
    }
  }, [cartId]);

  return { state, dispatch, submit };
}

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 1000+ projects.

  • React, Next.js, Node.js, TypeScript — production-grade stack
  • Fixed-price contracts — no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

XState v5: Complex Multi-State Flows

XState v5 (released 2024) introduces actors as the core abstraction, with setup() for full TypeScript inference:

// machines/checkout.machine.ts
import { setup, assign, fromPromise } from "xstate";

interface CheckoutContext {
  cartId: string;
  shippingAddress: Address | null;
  paymentMethod: PaymentMethod | null;
  orderId: string | null;
  error: string | null;
}

export const checkoutMachine = setup({
  types: {
    context: {} as CheckoutContext,
    events: {} as
      | { type: "NEXT" }
      | { type: "BACK" }
      | { type: "SET_SHIPPING"; address: Address }
      | { type: "SET_PAYMENT"; method: PaymentMethod }
      | { type: "SUBMIT" }
      | { type: "RETRY" },
  },
  actors: {
    submitOrder: fromPromise(async ({ input }: { input: { cartId: string } }) => {
      const res = await fetch("/api/orders", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ cartId: input.cartId }),
      });
      if (!res.ok) throw new Error(await res.text());
      return res.json() as Promise<{ orderId: string }>;
    }),
  },
  guards: {
    hasShipping: ({ context }) => context.shippingAddress !== null,
    hasPayment: ({ context }) => context.paymentMethod !== null,
  },
}).createMachine({
  id: "checkout",
  initial: "cart",
  context: ({ input }: { input: { cartId: string } }) => ({
    cartId: input.cartId,
    shippingAddress: null,
    paymentMethod: null,
    orderId: null,
    error: null,
  }),
  states: {
    cart: {
      on: {
        NEXT: "shipping",
      },
    },
    shipping: {
      on: {
        SET_SHIPPING: {
          actions: assign({ shippingAddress: ({ event }) => event.address }),
        },
        NEXT: {
          target: "payment",
          guard: "hasShipping",
        },
        BACK: "cart",
      },
    },
    payment: {
      on: {
        SET_PAYMENT: {
          actions: assign({ paymentMethod: ({ event }) => event.method }),
        },
        NEXT: {
          target: "review",
          guard: "hasPayment",
        },
        BACK: "shipping",
      },
    },
    review: {
      on: {
        SUBMIT: "submitting",
        BACK: "payment",
      },
    },
    submitting: {
      invoke: {
        src: "submitOrder",
        input: ({ context }) => ({ cartId: context.cartId }),
        onDone: {
          target: "success",
          actions: assign({ orderId: ({ event }) => event.output.orderId }),
        },
        onError: {
          target: "error",
          actions: assign({ error: ({ event }) => String(event.error) }),
        },
      },
    },
    success: { type: "final" },
    error: {
      on: {
        RETRY: {
          target: "review",
          actions: assign({ error: null }),
        },
      },
    },
  },
});

Using XState in React

// components/Checkout/CheckoutWizard.tsx
"use client";

import { useMachine } from "@xstate/react";
import { checkoutMachine } from "@/machines/checkout.machine";
import { CartStep } from "./CartStep";
import { ShippingStep } from "./ShippingStep";
import { PaymentStep } from "./PaymentStep";
import { ReviewStep } from "./ReviewStep";
import { SuccessStep } from "./SuccessStep";

const STEP_LABELS = {
  cart: "Cart",
  shipping: "Shipping",
  payment: "Payment",
  review: "Review",
  submitting: "Review",
  success: "Complete",
  error: "Review",
};

export function CheckoutWizard({ cartId }: { cartId: string }) {
  const [state, send] = useMachine(checkoutMachine, {
    input: { cartId },
  });

  const steps = ["cart", "shipping", "payment", "review"] as const;
  const currentStepIndex = steps.indexOf(state.value as any);

  return (
    <div className="max-w-2xl mx-auto">
      {/* Progress indicator */}
      <div className="flex gap-2 mb-8">
        {steps.map((step, i) => (
          <div
            key={step}
            className={`flex-1 h-1 rounded-full transition-colors ${
              i <= currentStepIndex ? "bg-blue-500" : "bg-gray-200"
            }`}
          />
        ))}
      </div>
      <p className="text-sm text-gray-500 mb-6">
        Step {Math.max(currentStepIndex + 1, 1)} of {steps.length}: {STEP_LABELS[state.value as keyof typeof STEP_LABELS]}
      </p>

      {/* Step components */}
      {state.matches("cart") && (
        <CartStep cartId={cartId} onNext={() => send({ type: "NEXT" })} />
      )}
      {state.matches("shipping") && (
        <ShippingStep
          onSubmit={(address) => {
            send({ type: "SET_SHIPPING", address });
            send({ type: "NEXT" });
          }}
          onBack={() => send({ type: "BACK" })}
        />
      )}
      {state.matches("payment") && (
        <PaymentStep
          onSubmit={(method) => {
            send({ type: "SET_PAYMENT", method });
            send({ type: "NEXT" });
          }}
          onBack={() => send({ type: "BACK" })}
        />
      )}
      {(state.matches("review") || state.matches("submitting") || state.matches("error")) && (
        <ReviewStep
          context={state.context}
          isSubmitting={state.matches("submitting")}
          error={state.context.error}
          onSubmit={() => send({ type: "SUBMIT" })}
          onRetry={() => send({ type: "RETRY" })}
          onBack={() => send({ type: "BACK" })}
        />
      )}
      {state.matches("success") && (
        <SuccessStep orderId={state.context.orderId!} />
      )}
    </div>
  );
}

React - React State Machines in 2026: XState v5, useReducer Patterns

🚀 Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Testing State Machines

// machines/checkout.machine.test.ts
import { createActor } from "xstate";
import { checkoutMachine } from "./checkout.machine";

describe("Checkout machine", () => {
  it("follows happy path cart → shipping → payment → review → success", async () => {
    const actor = createActor(checkoutMachine, {
      input: { cartId: "cart-123" },
    });
    actor.start();

    expect(actor.getSnapshot().value).toBe("cart");

    actor.send({ type: "NEXT" });
    expect(actor.getSnapshot().value).toBe("shipping");

    actor.send({ type: "SET_SHIPPING", address: { line1: "123 Main St", city: "NYC", zip: "10001" } });
    actor.send({ type: "NEXT" });
    expect(actor.getSnapshot().value).toBe("payment");

    actor.send({ type: "SET_PAYMENT", method: { type: "card", last4: "4242" } });
    actor.send({ type: "NEXT" });
    expect(actor.getSnapshot().value).toBe("review");

    // NEXT on payment without payment method should not advance
    actor.stop();
  });

  it("cannot advance past shipping without address", () => {
    const actor = createActor(checkoutMachine, {
      input: { cartId: "cart-123" },
    });
    actor.start();
    actor.send({ type: "NEXT" }); // → shipping
    actor.send({ type: "NEXT" }); // Should NOT advance without address

    expect(actor.getSnapshot().value).toBe("shipping"); // Still on shipping
  });

  it("allows retry after error", async () => {
    const actor = createActor(checkoutMachine, {
      input: { cartId: "bad-cart" }, // Will cause error
    });
    actor.start();

    // Fast-forward to error state
    // (In real test, mock the submitOrder actor to reject)
    // ...

    actor.send({ type: "RETRY" });
    expect(actor.getSnapshot().value).toBe("review");
  });
});

When to Use Each

ApproachUse When
Discriminated union + useStateSimple toggle flows, 2–3 states
useReducer finite stateMulti-step flows, 4–8 states, no parallel
XState v5Complex flows with parallel states, delayed transitions, actors, testing
React Query stateServer data loading states (don't use state machines for this)

Cost and Timeline

ComponentTimelineCost (USD)
useReducer state machine (simple wizard)0.5–1 day$400–$800
XState machine (complex flow)1–3 days$800–$2,500
State machine testing suite0.5–1 day$400–$800
Full wizard with XState1–2 weeks$5,000–$10,000

Additional Resources


The Viprasol Method

We model complex React UI flows as finite state machines — checkout wizards, onboarding flows, multi-step forms, and document processing pipelines. Our team has shipped XState-powered flows that eliminated entire categories of "impossible state" bugs in production.

What we deliver:

  • Flow analysis and state diagram before writing code
  • useReducer state machine for simpler flows
  • XState v5 with full TypeScript inference for complex flows
  • State machine test suite with transition coverage
  • UI components that render purely from machine state

Explore our web development services or contact us to model your complex UI flows.

ReactTypeScriptXStateState ManagementUXPatterns
Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms — we build it all with Next.js and React.

Free consultation • No commitment • Response within 24 hours

Viprasol · Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.