XState vs Zustand in 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
The Impossible States Problem
The key reason to reach for XState:
// Zustand — boolean flags can produce impossible states
const useRequestStore = create((set) => ({
loading: false,
success: false,
error: null,
// PROBLEM: All combinations of these booleans are possible:
// loading=true, success=true → IMPOSSIBLE
// loading=true, error=true → IMPOSSIBLE
// success=true, error=true → IMPOSSIBLE
// These impossible states cause subtle bugs
}));
// XState — impossible states are literally impossible
import { setup, assign } from 'xstate';
const requestMachine = setup({
types: {} as {
context: { data: unknown; error: string | null };
},
}).createMachine({
id: 'request',
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' } // Can go back to idle to retry
},
error: {
on: { RETRY: 'loading', RESET: 'idle' }
}
// There's NO way to be in "loading AND success" simultaneously
}
});
XState v5 Syntax
// XState v5 — significantly cleaner than v4
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: {
loginUser: fromPromise(({ input }: { input: Credentials }) =>
api.login(input)
),
},
}).createMachine({
id: 'auth',
initial: 'loggedOut',
context: { user: null, error: null },
states: {
loggedOut: {
on: {
LOGIN: {
target: 'loggingIn',
// Input passed to loginUser actor
}
}
},
loggingIn: {
invoke: {
src: 'loginUser',
input: ({ event }) => event.credentials,
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}
/>
);
}
Zustand for the Same Logic
// Zustand version — simpler but allows impossible states
const useAuthStore = create((set) => ({
status: 'loggedOut', // 'loggedOut' | 'loading' | 'loggedIn'
user: null,
error: null,
login: async (credentials) => {
set({ status: 'loading', error: null });
try {
const user = await api.login(credentials);
set({ status: 'loggedIn', user });
} catch (err) {
set({ status: 'loggedOut', error: err.message });
}
},
logout: () => set({ status: 'loggedOut', user: null }),
}));
For simple auth like this, Zustand with a status string is good enough. The Zustand version is easier to read and modify. XState shines when the state machine has many states and complex transitions.
When XState Is Worth It
Workflows where XState excels:
✓ Multi-step checkout flows (step1 → step2 → payment → confirmation)
✓ Form wizards with conditional branching
✓ Media players (idle → loading → playing → paused → ended)
✓ WebSocket connection management (connecting → connected → reconnecting → disconnected)
✓ Complex drag-and-drop interactions
✓ Game state management
✓ Authentication flows with multiple paths (login → MFA → onboarding → dashboard)
✓ Any workflow where "what state is this in?" is a common debugging question
When Zustand Is Sufficient
Cases where Zustand wins on simplicity:
✓ User preferences / settings
✓ Shopping cart (add/remove/update items)
✓ UI state (modals, sidebars, notifications)
✓ Simple data caching (fetched resources)
✓ Theme / dark mode
✓ Any state where boolean flags don't compound
Visual Tooling (XState Advantage)
Stately.ai — visual state machine editor:
- Draw state machines visually
- Generate TypeScript code automatically
- Inspect running machines in browser DevTools
- Share diagrams with non-technical stakeholders
This is uniquely valuable for:
- Documenting complex business logic
- Onboarding new developers
- Communicating with product/design teams
When to Choose
Choose XState when:
- Complex workflows with many possible states and transitions
- "Impossible state" bugs are occurring in your app
- Business logic benefits from explicit documentation via state diagrams
- Building multi-step flows (checkout, onboarding, wizards)
- Team benefits from visual tooling
Choose Zustand when:
- Simple app-level state (auth status, cart, UI)
- You want to ship quickly without state machine overhead
- Team is unfamiliar with finite state machines
- State transitions are simple and linear
They're not mutually exclusive — use XState for complex state machines and Zustand for simple global state.
Compare XState and Zustand package health on PkgPulse.
See the live comparison
View xstate vs. zustand on PkgPulse →