Skip to main content

XState vs Zustand in 2026: State Machines vs Simple Stores

·PkgPulse Team

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 simplersetup() 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.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.