XState vs Zustand 2026: State Machines vs Simple Stores
TL;DR
Zustand for most state; XState for complex workflows with explicit state transitions. Zustand (~10M weekly downloads) is a general-purpose store — simple, flexible, minimal overhead. XState (~2.5M downloads) models state as a finite state machine: every possible state and transition is explicit. XState v5 (released 2024) significantly simplified the API. Use XState when "impossible states" in your logic are causing bugs.
Key Takeaways
- Zustand: ~10M weekly downloads — XState: ~2.5M (npm, March 2026)
- XState eliminates impossible states — the core value proposition
- XState v5 is much simpler —
setup()API reduced boilerplate significantly - Zustand is better for simple state — XState overhead isn't worth it for basic cases
- XState has a visual editor — Stately.ai generates machines and visualizes state
State Machines vs Stores: The Core Difference
Zustand and XState are not competing solutions to the same problem — they're built on different mental models about what state is. Zustand is a store: a container for state values and the functions that modify them. Any state can transition to any other state. You set isLoading: true, you set isLoading: false, you set error: 'something went wrong'. There's no enforcement of which combinations are valid.
XState is a finite state machine (FSM) implementation. You define every possible state your application can be in, and every valid transition between those states. If you don't define a transition from state A to state B, that transition cannot happen. This is the core value proposition: impossible states become literally impossible because they aren't modeled. The machine is the source of truth for what can happen, not the developer's discipline in setting state correctly.
// Zustand — boolean flags can produce impossible states
const useRequestStore = create((set) => ({
loading: false,
success: false,
error: null,
data: null,
// PROBLEM: All combinations of these booleans are possible:
// loading=true, success=true → IMPOSSIBLE but representable
// loading=true, error='...' → IMPOSSIBLE but representable
// success=true, error='...' → IMPOSSIBLE but representable
// These impossible states cause subtle bugs in conditional rendering
}));
// Classic symptom: spinner showing at the same time as error message
// because loading was never reset when error was set
// XState — impossible states are literally impossible
import { setup, assign } from 'xstate';
const fetchMachine = setup({
types: {} as {
context: { data: unknown; error: string | null };
},
}).createMachine({
id: 'fetch',
initial: 'idle',
context: { data: null, error: null },
states: {
idle: {
on: { FETCH: 'loading' } // Can only go to loading from idle
},
loading: {
on: {
SUCCESS: {
target: 'success',
actions: assign({ data: ({ event }) => event.data })
},
FAILURE: {
target: 'error',
actions: assign({ error: ({ event }) => event.error })
},
}
},
success: { on: { RESET: 'idle' } },
error: { on: { RETRY: 'loading', RESET: 'idle' } },
// There is NO way to be in "loading AND success" simultaneously
// The type system enforces this — no discipline required
}
});
The tradeoff is clear: Zustand is simpler to get started with, but you're responsible for ensuring state transitions make sense. XState enforces valid transitions, but requires you to think upfront about the state model.
The "impossible states" problem is more common than it sounds. Any time you have two or more booleans that describe a request lifecycle — isLoading, isSuccess, isError — you have at least eight possible boolean combinations, of which maybe three are actually valid. Every if/else in your UI that checks these flags is implicitly trying to rule out the impossible ones. XState makes those rules explicit at the definition level rather than in every consuming component.
XState v5 API Improvements
XState v5, released in late 2024, dramatically simplified the library's API. XState v4 was notoriously verbose — machine definitions required deeply nested objects, TypeScript integration was awkward, and even simple machines felt ceremonious. Many developers tried XState v4, found it too complex for the problem they had, and moved on. If you're in that camp, v5 is worth reconsidering.
The v5 setup() API is the most significant change. It collocates the type definitions, actor definitions, and guard definitions at the top of the machine definition, then uses createMachine() for the actual state configuration. This makes TypeScript integration clean — you define your types once in setup() and the machine is fully typed. The new fromPromise(), fromObservable(), and fromCallback() actor creators make async operations much more composable.
// XState v4 — verbose, less ergonomic TypeScript
import { createMachine, assign } from 'xstate';
const authMachine = createMachine(
{
id: 'auth',
schema: {
context: {} as { user: User | null; error: string | null },
events: {} as
| { type: 'LOGIN'; credentials: Credentials }
| { type: 'LOGOUT' },
},
context: { user: null, error: null },
initial: 'loggedOut',
states: {
loggedOut: { on: { LOGIN: 'loggingIn' } },
loggingIn: {
invoke: {
src: 'loginUser', // Defined separately in options
onDone: { target: 'loggedIn', actions: 'setUser' },
onError: { target: 'loggedOut', actions: 'setError' },
},
},
loggedIn: { on: { LOGOUT: 'loggedOut' } },
},
},
{
services: { loginUser: (ctx, event) => api.login(event.credentials) },
actions: {
setUser: assign({ user: (ctx, event) => event.data }),
setError: assign({ error: (ctx, event) => event.data.message }),
},
}
);
// XState v5 — setup() API, much cleaner
import { setup, assign, fromPromise } from 'xstate';
import { useMachine } from '@xstate/react';
const authMachine = setup({
types: {} as {
context: { user: User | null; error: string | null };
events:
| { type: 'LOGIN'; credentials: Credentials }
| { type: 'LOGOUT' };
},
actors: {
// Actor defined inline with full type safety
loginUser: fromPromise(({ input }: { input: Credentials }) =>
api.login(input)
),
},
}).createMachine({
id: 'auth',
initial: 'loggedOut',
context: { user: null, error: null },
states: {
loggedOut: { on: { LOGIN: 'loggingIn' } },
loggingIn: {
invoke: {
src: 'loginUser',
input: ({ event }) => event.credentials, // Typed — TS knows event.credentials exists
onDone: {
target: 'loggedIn',
actions: assign({ user: ({ event }) => event.output }),
},
onError: {
target: 'loggedOut',
actions: assign({ error: ({ event }) => String(event.error) }),
},
}
},
loggedIn: {
on: { LOGOUT: { target: 'loggedOut', actions: assign({ user: null }) } }
},
}
});
// Usage in React
function AuthComponent() {
const [state, send] = useMachine(authMachine);
if (state.matches('loggingIn')) return <Spinner />;
if (state.matches('loggedIn')) return <Dashboard user={state.context.user} />;
return (
<LoginForm
onSubmit={(creds) => send({ type: 'LOGIN', credentials: creds })}
error={state.context.error}
/>
);
}
The v5 version is roughly half the lines of the v4 version, with better TypeScript inference throughout.
The Stately.ai Ecosystem
XState's most unique advantage over every other state management library is the Stately.ai visual editor. Stately.ai lets you design state machines graphically — you draw states as boxes, connect them with labeled arrows for transitions, and the editor generates valid XState v5 TypeScript code. You can also import existing machine code and visualize it.
This creates a feedback loop that no other state management library offers: you can open any XState machine in Stately.ai and see a diagram of all possible states and transitions. This is uniquely valuable for onboarding new developers (they see the diagram before reading the code), communicating with product teams (you can share a link to the visual diagram), and debugging (you can trace exactly which transition produced a bug).
Stately.ai features:
- Visual state machine designer (web-based)
- Export to TypeScript code (XState v5)
- Import existing XState code and visualize it
- Share diagram links with teammates
- Live simulation — walk through state transitions interactively
- Browser DevTools panel — inspect running machines in real time
- Team features — collaborate on machines with your team
For a complex checkout flow, the diagram shows:
idle → cartReview → shippingInfo → paymentInfo → orderConfirmation
↑ ↑ ↑
(back) (back) (back)
↓
processingPayment
↙ ↘
success paymentError
↓
(retry → paymentInfo)
This level of visual documentation is impossible with Zustand or any other store-based library.
When XState Pays Off
The best way to understand when XState is worth its complexity overhead is to look at specific use cases where impossible states actually occur in practice.
A multi-step form wizard is a canonical example. The form has steps 1, 2, and 3. Step 3 requires valid data from step 2. Can you submit step 3 without completing step 2? Can you go back from step 3 without losing step 3's data? Can you jump from step 1 to step 3? With boolean flags or a simple step counter, you have to manually enforce these rules. With XState, the machine defines exactly which transitions are valid.
// Multi-step form wizard with XState
const checkoutMachine = setup({
types: {} as {
context: {
cartData: CartData | null;
shippingData: ShippingData | null;
paymentData: PaymentData | null;
};
},
}).createMachine({
id: 'checkout',
initial: 'cart',
context: { cartData: null, shippingData: null, paymentData: null },
states: {
cart: {
on: { NEXT: { target: 'shipping', actions: assign({ cartData: ({ event }) => event.data }) } }
},
shipping: {
on: {
NEXT: { target: 'payment', actions: assign({ shippingData: ({ event }) => event.data }) },
BACK: 'cart',
}
},
payment: {
on: {
SUBMIT: 'processing',
BACK: 'shipping',
}
},
processing: {
invoke: {
src: 'submitOrder',
onDone: 'confirmation',
onError: 'paymentError',
}
},
confirmation: { type: 'final' },
paymentError: {
on: { RETRY: 'payment' }
},
}
});
// You cannot reach 'confirmation' without going through every step
// You cannot reach 'processing' without valid payment data
// These constraints are enforced by the machine definition, not by discipline
Other cases where XState excels: WebSocket connection management with reconnection logic, media player controls (idle/loading/playing/paused/buffering/error), authentication with MFA flows, and any onboarding wizard with conditional branching.
When Zustand Is Enough
Most application state does not need state machines. Shopping cart contents, user preferences, theme settings, modal visibility, notification queues, and cached API responses are all straightforward state that Zustand handles cleanly. For this kind of state, XState's state machine overhead adds complexity without meaningful benefit.
// Zustand is the right tool for this kind of state
const useAppStore = create<AppStore>((set) => ({
// UI state — no complex transitions needed
isSidebarOpen: false,
theme: 'light' as 'light' | 'dark',
notifications: [] as Notification[],
// Shopping cart — straightforward mutations
cartItems: [] as CartItem[],
addToCart: (item) => set(state => ({ cartItems: [...state.cartItems, item] })),
removeFromCart: (id) => set(state => ({
cartItems: state.cartItems.filter(i => i.id !== id)
})),
// User preferences — simple updates
user: null as User | null,
setUser: (user) => set({ user }),
toggleSidebar: () => set(state => ({ isSidebarOpen: !state.isSidebarOpen })),
setTheme: (theme) => set({ theme }),
addNotification: (n) => set(state => ({ notifications: [...state.notifications, n] })),
dismissNotification: (id) => set(state => ({
notifications: state.notifications.filter(n => n.id !== id)
})),
}));
The rule of thumb: if you're writing if (isLoading && isError) and wondering whether that combination is possible, you need XState. If you're just reading and writing simple values, Zustand is the right tool.
Actor Model and Parallel States
One of XState v5's most powerful features beyond basic state machines is the actor model. Actors are independent units of behavior — each actor has its own state, receives messages, and sends messages to other actors. XState v5 makes actors composable: you can spawn child actors from a parent machine, coordinate multiple parallel workflows, and cancel actors when they're no longer needed.
This is particularly useful for complex async orchestration. If you have a checkout flow that needs to simultaneously validate the payment, check inventory, and reserve the shipping slot — and all three must complete before the order is confirmed — you can model each as a parallel actor and coordinate them in the parent machine. Zustand has no concept of actor coordination; you'd implement this with a combination of Promise.all() and manual state management, which works but provides no structural guarantee that the coordination logic is correct.
// XState — parallel states for concurrent workflows
import { setup, fromPromise } from 'xstate';
const orderMachine = setup({
actors: {
validatePayment: fromPromise(({ input }) => api.validatePayment(input)),
checkInventory: fromPromise(({ input }) => api.checkInventory(input)),
reserveShipping: fromPromise(({ input }) => api.reserveShipping(input)),
},
}).createMachine({
id: 'order',
initial: 'processing',
states: {
processing: {
// Parallel states — all run concurrently
type: 'parallel',
states: {
payment: {
initial: 'validating',
states: {
validating: {
invoke: { src: 'validatePayment', onDone: 'done', onError: '#order.failed' }
},
done: { type: 'final' },
},
},
inventory: {
initial: 'checking',
states: {
checking: {
invoke: { src: 'checkInventory', onDone: 'done', onError: '#order.failed' }
},
done: { type: 'final' },
},
},
},
// All parallel regions must reach 'done' before transitioning
onDone: 'confirmed',
},
confirmed: { type: 'final' },
failed: {},
},
});
// The machine automatically handles: if payment fails, cancel inventory check
// If both succeed, proceed to confirmed — no manual coordination needed
This level of concurrent workflow modeling is where XState v5's actor model genuinely has no equivalent in Zustand. The machine definition is the documentation, the implementation, and the type system all at once.
Package Health
| Metric | zustand | xstate |
|---|---|---|
| Weekly downloads | ~10M | ~2.5M |
| GitHub stars | ~48k | ~27k |
| Maintainer | Poimandres | Stately (David Khourshid) |
| Current version | v5 stable | v5 stable |
| Bundle size | ~3KB gzipped | ~15KB gzipped |
| TypeScript | Full support | Full support |
| React integration | Built-in hooks | @xstate/react |
XState's larger bundle size is expected — it includes the state machine interpreter, actor model, and TypeScript types for complex machine definitions. For most apps, this is negligible. Both libraries are actively maintained with strong commercial backing (Poimandres for Zustand, Stately for XState).
The fact that XState's download count is roughly a quarter of Zustand's does not mean XState is less valuable — it means it addresses a narrower problem space. Zustand is used in virtually every type of React app; XState is specifically chosen for apps with complex workflow logic. Many production apps use both: Zustand for global state that's straightforward, and XState for the specific workflows — checkout, onboarding, media player — where a state machine pays off.
When to Choose
Choose Zustand when:
- Managing typical app state: cart, user, UI, notifications, preferences
- You want to ship quickly without learning state machine concepts
- Your state transitions are simple and linear — no branching or impossible state concerns
- Your team is new to the project and you want low conceptual overhead
- You need state that's accessible outside React components
Choose XState when:
- Building multi-step workflows: checkout, onboarding, form wizards
- You're debugging "impossible state" bugs where boolean flags combine in unexpected ways
- Your team will benefit from visual state documentation via Stately.ai
- Complex async orchestration: parallel actors, cancellation, retry logic
- Authentication flows with multiple paths (login → MFA → 2FA → onboarding → dashboard)
They complement each other: use Zustand for global app state, and drop in XState machines for the specific workflows that need explicit state modeling.
For a full side-by-side package health breakdown, see the XState vs Zustand comparison page. If you're considering Zustand alongside other reactive approaches, the MobX vs Zustand article explores the tradeoffs between observable-based and explicit store models. Current XState release history and download trends are on the XState package page.
See the live comparison
View xstate vs. zustand on PkgPulse →