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.
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
| Approach | Use When |
|---|---|
| Discriminated union + useState | Simple toggle flows, 2โ3 states |
| useReducer finite state | Multi-step flows, 4โ8 states, no parallel |
| XState v5 | Complex flows with parallel states, delayed transitions, actors, testing |
| React Query state | Server data loading states (don't use state machines for this) |
Cost and Timeline
| Component | Timeline | Cost (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 suite | 0.5โ1 day | $400โ$800 |
| Full wizard with XState | 1โ2 weeks | $5,000โ$10,000 |
See Also
- React Optimistic Updates โ Managing optimistic state alongside machine state
- React Testing Library Patterns โ Testing wizard components
- SaaS Onboarding Checklist โ Multi-step onboarding flow
- React Form Builder โ Dynamic forms inside wizard steps
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.
About the Author
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.
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
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.