TypeScript state machines using XState help you build predictable UI logic. Stop relying on fragile boolean flags and start modeling impossible states away.
I remember staring at a loading, error, and success boolean mess in a component that had grown to over 600 lines. Every time we added a new feature—like a retry button—a new permutation of these booleans would trigger an impossible UI state, like showing both a loading spinner and a success message simultaneously. That was the moment I realized my frontend architecture needed a radical change.
If you’re tired of debugging "impossible" UI states, it’s time to look at TypeScript state machines. By combining the strictness of TypeScript with the formal logic of XState, you can create interfaces that are literally impossible to break.
Most developers start by managing state with loose objects. You’ve seen this before: const [state, setState] = useState({ loading: false, data: null, error: null }). It’s simple, but it’s dangerous. Nothing stops you from setting loading: true and error: 'Something went wrong' at the same time.
In my experience, moving to discriminated unions in TypeScript: Modeling state without bugs is the first step toward sanity. It allows you to define mutually exclusive states, ensuring that if you're in an error state, you aren't also accidentally in a loading state.
While native discriminated unions are great for simple components, XState provides a dedicated engine for finite state machines. It forces you to define every valid transition upfront.
Here is how I typically structure a fetching machine in TypeScript:
TYPESCRIPTimport { createMachine, assign } from CE9178">'xstate'; type FetchState = | { value: CE9178">'idle'; context: {} } | { value: CE9178">'loading'; context: {} } | { value: CE9178">'success'; context: { data: string } } | { value: CE9178">'error'; context: { error: Error } }; const fetchMachine = createMachine<FetchState>({ id: CE9178">'fetcher', initial: CE9178">'idle', states: { idle: { on: { FETCH: CE9178">'loading' } }, loading: { on: { RESOLVE: { target: CE9178">'success', actions: assign({ data: (_, e) => e.data }) }, REJECT: { target: CE9178">'error', actions: assign({ error: (_, e) => e.error }) } } }, success: { type: CE9178">'final' }, error: { on: { RETRY: CE9178">'loading' } } } });
This code is self-documenting. By defining the FetchState union, TypeScript understands exactly which context is available in each state. If you try to access context.data while the machine is in the error state, the compiler will throw an error immediately.
The real power of TypeScript state machines shows up when you integrate them into your React components. Using the @xstate/react package, you get type-safe access to the current state.
TSXconst { state, send } = useMachine(fetchMachine); if (state.matches(CE9178">'loading')) return <Spinner />; if (state.matches(CE9178">'error')) return <ErrorMessage error={state.context.error} />; if (state.matches(CE9178">'success')) return <div>{state.context.data}</div>;
Because XState knows the shape of your state, state.context.error is only accessible inside the error check. This pattern effectively eliminates the "undefined" errors that plague large-scale applications.
I initially tried to implement a custom state machine library to keep the bundle size small. It lasted about two weeks before we hit a edge case with nested states that the library couldn't handle. We ended up refactoring to XState (version 4.x at the time).
The learning curve was roughly three days of frustration followed by months of peace. If you're building something complex, don't roll your own. Use the tools that have already solved the hard problems of event propagation and history tracking.
For a simple, one-input form, yes. But for multi-step wizards or complex data synchronization, it’s a lifesaver. If your component has more than three boolean flags, it’s time to move to a state machine.
The runtime overhead of XState is negligible—usually around 12kb minified. The performance gain from avoiding unnecessary re-renders caused by inconsistent state updates far outweighs the footprint.
Absolutely. It pairs perfectly with Next.js Server Actions: Implementing Type-Safe Mutations and Middleware to ensure your client-side UI stays in sync with your backend state.
Transitioning to finite state machines changed how I approach frontend architecture. It shifted my mindset from "how do I handle this specific event" to "what are the valid states of this feature?"
I’m still experimenting with how to best share state machines across workers in a Web Worker-heavy application, as the serialization of complex machine objects can be tricky. But for standard UI logic, this approach has saved me countless hours of debugging. Start small—try converting a single complex component to a machine this week and see if your bug reports drop.
TypeScript recursive conditional types help you prevent impossible states in complex configuration objects. Learn to enforce deep type safety at compile-time.
Read moreTypeScript Value Objects help you eliminate primitive obsession by wrapping raw data in domain-specific types. Learn to prevent bugs with better type safety.