Back to Blog

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

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
February 14, 2027
13 min read

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

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 100+ 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>
  );
}

๐Ÿš€ 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

See Also


Working With Viprasol

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.

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 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

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.