Back to Blog
TypeScriptJavaScriptJune 21, 20264 min read

TypeScript State Machines: Building Predictable UI Logic with XState

TypeScript state machines using XState help you build predictable UI logic. Stop relying on fragile boolean flags and start modeling impossible states away.

TypeScriptXStateFrontend ArchitectureState ManagementReactJavaScriptFrontend

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.

Why Boolean Flags Fail

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.

Modeling with XState

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:

TYPESCRIPT
import { 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 Payoff: Type-Safe UI

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.

TSX
const { 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.

Lessons from the Trenches

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.

Frequently Asked Questions

Is XState overkill for simple forms?

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.

How does this affect performance?

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.

Can I use this with async data?

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.

Final Thoughts

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.

Similar Posts