There is a comment on Hacker News I keep coming back to because it is the whole review in two sentences. A developer, writing in late 2024, says XState is "an insanely overcomplicated and over-engineered piece to a point of being unusable." Then, in the same comment, they add: "despite my hate towards overengineered things, xstate + stately has been incredibly useful to me on a number of projects and I am a fan of it."
That is not a person who is confused. That is a person who has correctly identified that XState is two things at once, and that both readings are true. I have spent a while now reading the docs closely, mapping the v5 API, and going through the trail of production write-ups and community threads people have left behind — and the honest verdict is not "good" or "bad." It is that XState is a high-ceiling tool with a real bill attached, and whether you should reach for it comes down entirely to whether your problem is shaped like the one it solves. Most problems aren't. Some are, badly, and for those it is close to the best thing going.
Let me try to draw the line precisely, because "it depends" is where most reviews of this library stop, and "it depends" is useless without the on what.
What it actually is
XState is, in its own words, "a state management and orchestration solution for JavaScript and TypeScript apps". Underneath the marketing that phrase is doing real work: it is a library for building statecharts — David Harel's 1987 formalism that extends plain finite state machines with hierarchy, parallelism, and history — plus an actor model layered on top. Zero runtime dependencies, MIT-licensed, and it runs anywhere JavaScript runs; React, Vue, and Svelte are just view layers you bolt on.
The one idea that makes the whole thing worth understanding is this: a state machine makes impossible states impossible. Most UI bugs are a boolean soup problem. You have isLoading, isError, hasData, isEditing, and somewhere in your component a combination like loading and error at the same time is nonsense — but nothing stops you from being in it, because four booleans encode sixteen states and only five of them are real. A machine flips that. You declare the five real states and the exact transitions between them, and the eleven nonsense states become literally unrepresentable. You can't be in them, because there is no edge that leads there.
Here is the smallest possible taste. This is v5 syntax, verified against the current Stately docs:
import { createMachine } from 'xstate';
const toggleMachine = createMachine({
id: 'toggle',
initial: 'Inactive',
states: {
Inactive: { on: { toggle: 'Active' } },
Active: { on: { toggle: 'Inactive' } },
},
});
Trivial — a toggle doesn't need a library. But the shape scales. Add a typed context (the "infinite" data a machine carries alongside its finite state), named actions, and an async call, and you get the pattern that actually earns its keep. In v5 you assemble it through setup(), which is where types and named implementations live before you build the machine:
import { setup, fromPromise, assign } from 'xstate';
const machine = setup({
types: {
context: {} as { user: User | null; userId: string },
events: {} as { type: 'load' },
},
actors: {
fetchUser: fromPromise<User, { userId: string }>(async ({ input }) => {
const res = await fetch(`/api/users/${input.userId}`);
return res.json();
}),
},
}).createMachine({
initial: 'idle',
context: { user: null, userId: '42' },
states: {
idle: { on: { load: 'loading' } },
loading: {
invoke: {
src: 'fetchUser',
input: ({ context }) => ({ userId: context.userId }),
onDone: {
target: 'success',
actions: assign({ user: ({ event }) => event.output }),
},
onError: 'failure',
},
},
success: {},
failure: { on: { load: 'loading' } },
},
});
Read that and notice what you didn't have to write: no useEffect to fire the fetch, no manual setError in a catch, no guard against a stale response landing after the user navigated away. The loading state owns its async operation via invoke; success and failure are states, not flags; and there is no path from idle straight to success. The failure modes you'd normally defend against by hand are excluded by the graph. This is the good part, and it is genuinely good.
The part that is real, and paid for
Now the bill.
The learning curve is the single most-cited cost, and it is not a beginner problem. Nearly every practitioner write-up names it, enthusiasts included — the mental shift from imperative "set this flag" to declarative "define these transitions" is the kind people compare not to picking up another state manager but to learning RxJS or fp-ts. One deeply hands-on 2025 retrospective by Mauricio Duarte puts it flatly: "The learning curve is steep – really steep." This matters more than it sounds, because a learning curve is not a one-time personal cost — it is a team cost, paid on every hire and every code review, forever. The recurring failure story across community threads is identical: one senior introduces XState, writes elegant machines, and the rest of the team can't maintain them. As one Reddit commenter described the aftermath, "the project is just completely broken" — not because the library failed, but because the team-wide fluency it assumed never materialized.
Verbosity is real and measurable. A machine definition is almost always longer than the hook code it replaces. One careful refactor documented the trade at +714 lines of machine definitions against −119 lines removed from components. The author considered it worth it — "I would rather take a hit to the bundle size than face the hell of useEffect heavy components again" — but that is a considered acceptance of a cost, not an absence of one. The bundle is part of that cost too: ~14.5 kB gzipped for the core (as of July 2026), which the comparison blogs uniformly file under "large" next to Zustand.
The TypeScript story is better than it was, and still has a sharp edge. v5 dropped the old codegen step (typegen) in favor of inference through setup(), and by most accounts that's a real improvement — one experienced user calls it "a big improvement in most respects." But the improvement is conditional in a way worth knowing before you commit. Inference holds while your implementations stay inline inside setup(...).createMachine(...). Pull an action out into a named, standalone function in another file — the ordinary way you manage complexity in TypeScript — and inference tends to break, because assign's type signature is hostile to being factored out. The same user names the trap exactly: "if everything has to be inline, you lose access to some of the language's fundamental tools for wrangling complexity — things like assigning values to named constants and putting some of your code in other files." So v5 didn't kill the old "XState fights TypeScript" complaint. It moved it to the module boundary.
None of these are defects, and that distinction is the crux of the whole review. Almost nobody who has used XState seriously thinks it is badly built — the near-universal read is that it is a very well-made tool that is easy to point at the wrong problem. The critique is mis-fit, not malfunction.
Where it wins, and where it's a tax
So here is the line, as sharp as I can make it.
Reach for XState when the problem is intrinsically a flow — an event-driven process with real modes, restricted transitions, and coordinated side effects. The community's list of sweet spots is remarkably consistent across years and sources: multi-step wizards and onboarding, checkout and payment forms, 2FA and auth flows, media players, kiosks, game and animation states, long-running application processes where a backend tracks a user's position in the machine. The tell is when you can say a sentence like "you must not be able to go from waiting straight to success" and mean it — when the transitions themselves are the thing you need to constrain. That is exactly what a statechart encodes and what a pile of booleans cannot.
Do not reach for it for everyday application state, and above all not for server data. For a CRUD app whose logic, in one migrating team's words, "boils down to one or two top level conditions", a machine is ceremony without payoff. The most quoted horror stories are all this shape: XState wrapped around simple state, later removed. One team migrated such a feature to Zustand and deleted 80% of the state-management code; their conclusion was not "XState is bad" but "simple beats clever" and "not every form needs a state machine." And for server data specifically, one practitioner in a thread on large-scale usage put it bluntly: "in theory xState > React Query but in practice React query > xState." Server cache is a solved problem, and the tool that solves it is TanStack Query, not a state machine.
The clearest way to hold this: XState is state orchestration, not state management. It is not a Redux or Zustand replacement, and reading it as one is the number-one source of the disappointment you'll find in any thread. Even in an enthusiast's own love letter to the library, the line is right there: "XState is not a replacement to redux, I still like my redux." Use it to orchestrate a pocket of genuine complexity. Do not make it your store.
The moat, and v5, and the small door
Two things deserve their own mention because they change the calculus.
The first is the tooling, which is the piece even critics praise with the least reservation. Because a machine is a serializable data structure, Stately's visual editor can render it as a live diagram — one you can design before writing code, simulate to catch missing transitions, and, crucially, show to a non-engineer. This recurs everywhere as a secondary benefit that turns out to be primary: the diagram is a communication artifact that a product manager or designer can read, and unlike hand-drawn architecture diagrams it can't drift out of date, because it is the code. If your bottleneck is shared understanding of a complex flow across a mixed team, this alone can justify the tool.
The second is v5 (shipped December 2023) and the ecosystem's quiet admission bundled with it. v5 rebuilt XState around the actor model — everything is an actor, async work is modeled with logic creators like fromPromise, and machines get input, output, and deep persistence. The reception is "right direction, real friction": nobody argues it was a wrong turn, and nobody recommends staying on v4, but it did not dissolve the learning-curve-and-verbosity complaints — it modernized the architecture underneath them. What's more telling is @xstate/store, a small companion library the team shipped in 2024 that is essentially "Zustand with events" — event-driven, great TypeScript inference, and an upgrade path to a full machine if complexity later shows up. The existence of that small door is the ecosystem conceding, in code, that the full library is overkill for most everyday state. Even the library's creator agrees in public that it is "complicated and overly engineered for many applications that don't need all of the statechart features," and says the next major version will simplify. (@xstate/store is not a clean win either — reviews split on whether it's worth switching from Zustand or ends up too limited to justify itself. But its mere existence tells you where the honest boundary sits.)
One number frames the whole market position. In the State of React survey, XState sits at roughly 11% "have used it" — bottom of the tracked state libraries and essentially flat year over year — while Zustand rocketed from the high-20s past 40% in the same window. That is not a library dying. It is a library that correctly found its size: a respected, deliberately-chosen niche tool, not a default.
The verdict
Take XState if you have a genuinely complex, long-lived, flow-shaped problem and a team that will invest in learning it — not one person, the team. In that intersection it delivers what almost nothing else does: impossible states made impossible, async and edge cases handled by the graph instead of by hand, and a living diagram the whole org can reason about. The people who aim it there report durable wins in maintainability and confidence, and I believe them.
Skip it for simple CRUD, for small forms, for server-cache data, and for the role of app-wide store. Reach for Zustand or useReducer for local and shared state, TanStack Query for server data, and @xstate/store if you want event-driven structure without the full machine. And if you're a solo developer who can't get the team to come along, skip it too — a tool that becomes organizational debt the moment you leave is the wrong tool no matter how elegant, and elegance you can't hand off is just a liability with good taste.
Here is the connection I find quietly telling, and it's the note I want to end on. Across years of these write-ups, the most common long-term outcome isn't "we adopted XState" or "we ripped it out." It's a third thing: people keep the statechart mindset and drop the library. They start modeling their auth flow as explicit states and transitions in their head, then in a plain TypeScript discriminated union, and they report their code got better just from looking at state differently — no library required. Which suggests the most valuable thing XState has to offer might not be the 14.5 kB of code at all. It's the idea the code is built on. You can adopt the idea for free, decide your problem is actually shaped like a machine, and only then reach for the library that implements the idea best. In that order, the bill is a lot easier to justify — and the tool is a lot harder to point at the wrong problem.