loke.dev
Cover image for 5 State Machines That Will Save Your Frontend From Boolean Hell

5 State Machines That Will Save Your Frontend From Boolean Hell

Stop juggling 'isLoading' and 'isError' flags and start building UI logic that is mathematically impossible to break.

· 4 min read

I spent three hours yesterday debugging a "phantom" loading spinner that refused to disappear even after the data had successfully rendered. It turned out I had isLoading, isError, and isSuccess all set to true at the same time. I was playing a game of whack-a-mole with booleans, and I was losing. The fix wasn't another useEffect or a more complex if statement; it was admitting that my component had reached a level of complexity that simple variables couldn't describe.

We treat UI state like a collection of independent flags, but in reality, UI behaves more like a sequence of exclusive modes. When you’re in a "Loading" state, it is physically impossible to also be in an "Error" state. State machines turn this logic from a pinky-promise into a mathematical guarantee.

Here are five state machines that will make your frontend logic boring—in the best way possible.

1. The "Robust Fetcher"

The standard const [loading, setLoading] = useState(false) is a trap. If an error occurs and you forget to set loading to false, your UI hangs. If you retry a request without clearing the previous error, you get a flickering mess.

A Fetcher machine limits your world to four distinct possibilities: idle, loading, success, and failure.

const fetchMachine = {
  initial: 'idle',
  states: {
    idle: { ON: { FETCH: 'loading' } },
    loading: {
      ON: {
        RESOLVE: 'success',
        REJECT: 'failure',
      },
    },
    success: { ON: { FETCH: 'loading' } },
    failure: { ON: { FETCH: 'loading' } },
  },
}

Why this saves you: You can’t trigger a RESOLVE action while in the idle state. The machine simply ignores it. This eliminates race conditions where an old API response returns after you've already navigated away or restarted the process.

2. The Multi-Step "Wizard"

Conditional rendering for forms usually looks like a nested nightmare: step === 1 && <StepOne />. But what happens when the user clicks the "Back" button on step 3? Or tries to skip to step 4 by manipulating the URL?

const checkoutMachine = {
  initial: 'cart',
  states: {
    cart: { ON: { NEXT: 'shipping' } },
    shipping: { ON: { NEXT: 'payment', PREV: 'cart' } },
    payment: { ON: { NEXT: 'review', PREV: 'shipping' } },
    review: { ON: { CONFIRM: 'processing', PREV: 'payment' } },
    processing: { ON: { DONE: 'complete' } },
    complete: { type: 'final' },
  },
}

The logic: By defining "transitions," you prevent the user from jumping from cart to review. The UI only renders what the current state dictates. If the state is shipping, the only valid exits are forward to payment or back to cart.

3. The "Trigger-Happy" Submit Button

We’ve all seen it: a user double-clicks a "Submit" button and accidentally places two orders. The "disabled" attribute on a button is a weak defense. A state machine makes the "Submit" action literally non-existent once the process starts.

const submitMachine = {
  initial: 'idle',
  states: {
    idle: {
      ON: { SUBMIT: 'submitting' },
    },
    submitting: {
      // The SUBMIT event is not handled here.
      // Clicking again does absolutely nothing.
      ON: {
        SUCCESS: 'disabled',
        ERROR: 'idle',
      },
    },
    disabled: {},
  },
}

4. The Auth Boundary

Authentication is rarely a binary isLoggedIn. You have the "checking session" phase, the "anonymous" phase, the "MFA required" phase, and the "authenticated" phase.

Juggling these with booleans leads to "Flash of Unauthenticated Content" (FOUC).

const authMachine = {
  initial: 'checking',
  states: {
    checking: {
      ON: {
        FOUND_SESSION: 'authenticated',
        NO_SESSION: 'anonymous',
      },
    },
    anonymous: { ON: { LOGIN: 'authenticating' } },
    authenticating: {
      ON: {
        SUCCESS: 'authenticated',
        REQUIRE_MFA: 'awaitingMFA',
        FAILURE: 'anonymous',
      },
    },
    awaitingMFA: { ON: { VERIFY: 'authenticated' } },
    authenticated: { ON: { LOGOUT: 'anonymous' } },
  },
}

The Gotcha: Notice the checking state. By making this an explicit state, you can render a specific "Splash Screen" instead of accidentally showing the Login page for 200ms while your session cookie is being validated.

5. The "Search-as-you-type" Autocomplete

This is the final boss of frontend state. You have to manage input debouncing, API calls, empty states, and the possibility that the user clears the input while a request is mid-flight.

A machine for this looks for specific sequences:

  1. idle -> typing (start timer)
  2. typing -> searching (timer ends, trigger API)
  3. searching -> results OR noResults

If the user types while in the searching state, the machine transitions back to typing, effectively ignoring the results of the now-stale API call.

How to implement this today

You don't need a heavy library to start. While XState is the gold standard for complex logic, you can implement a basic state machine using a simple useReducer hook in React.

const reducer = (state, action) => {
  const nextState = fetchMachine.states[state].ON[action]
  return nextState || state // Return current state if transition is invalid
}

The magic isn't in the code; it's in the map. Before you write a single line of JSX, draw the circles and arrows on a piece of paper. If you can't draw the transition, you shouldn't be writing the code.

Stop building UIs that "hope" the state is correct. Build UIs that can't be wrong.