Skip to main content

Command Palette

Search for a command to run...

React State Machines: XState for Complex UIs

Updated
3 min readView as Markdown
React State Machines: XState for Complex UIs
P
Hi, I'm Paresh! I'm a full-stack developer based in Ahmedabad, India, working remotely to build scalable web and mobile applications. My core technical stack includes Laravel, Flutter, PHP, JavaScript, PostgreSQL, and MySQL. I'm passionate about the entire product lifecycle—from architecture and coding to SEO and digital marketing. Currently, I'm focused on growing smarttechdevs.in and developing impactful, real-world products

The Nightmare of Multiple Booleans

When building data-fetching components in React, developers usually start simple: const [isLoading, setIsLoading] = useState(false). But as the B2B SaaS feature grows, the state becomes a fragile house of cards.

Suddenly, your component looks like this:
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [isRetrying, setIsRetrying] = useState(false)

This is known as Boolean Soup. Mathematically, four booleans can exist in 16 different combinations. Is it possible for a component to be isLoading: true AND isError: true AND isSuccess: true at the exact same time? In standard React, yes. This leads to impossible UI states, flashing error messages during loading screens, and incredibly brittle useEffect logic.

The Solution: Finite State Machines (FSM)

To architect predictable UI, a component should only ever be in one exact state at a time. It is either 'idle', 'loading', 'success', or 'error'. It cannot be two things at once. We enforce this strict architecture using Finite State Machines, powered by a library called XState.

Architecting an XState Machine

Let's build a robust data-fetching machine. Instead of mutating booleans, we define strict states and the "transitions" allowed between them.


// machines/fetchMachine.ts
import { createMachine } from 'xstate';

export const fetchMachine = createMachine({
    id: 'dataFetcher',
    // 1. The machine STARTS in the 'idle' state
    initial: 'idle',
    states: {
        idle: {
            // From 'idle', the only valid action is 'FETCH', which moves it to 'loading'
            on: { FETCH: 'loading' }
        },
        loading: {
            // From 'loading', it can either succeed or fail
            on: {
                RESOLVE: 'success',
                REJECT: 'error'
            }
        },
        success: {
            // A terminal state. Or we could allow 'REFRESH' to go back to 'loading'
            on: { REFRESH: 'loading' }
        },
        error: {
            // From 'error', the user can hit 'RETRY'
            on: { RETRY: 'loading' }
        }
    }
});

Consuming the Machine in React

Using the @xstate/react hook, we bind our component to the machine. Notice how clean the rendering logic becomes. We do not check combinations of variables; we simply check the exact value of state.value.


// components/SecureDataPanel.tsx
"use client";

import { useMachine } from '@xstate/react';
import { fetchMachine } from '@/machines/fetchMachine';
import { useEffect } from 'react';

export default function SecureDataPanel() {
    const [state, send] = useMachine(fetchMachine);

    const fetchData = async () => {
        send({ type: 'FETCH' }); // Transition to 'loading'
        try {
            // Simulate API call
            await new Promise(res => setTimeout(res, 1500));
            send({ type: 'RESOLVE' }); // Transition to 'success'
        } catch (err) {
            send({ type: 'REJECT' }); // Transition to 'error'
        }
    };

    return (
        <div className="p-6 border rounded-lg shadow">
            {/* The UI strictly maps to the exact state machine value */}
            
            {state.matches('idle') && (
                <button onClick={fetchData} className="btn-primary">Load Dashboard Data</button>
            )}

            {state.matches('loading') && (
                <div className="animate-pulse">Loading secure data...</div>
            )}

            {state.matches('error') && (
                <div className="text-red-500">
                    <p>Connection failed.</p>
                    <button onClick={() => send({ type: 'RETRY' })} className="btn-outline">Try Again</button>
                </div>
            )}

            {state.matches('success') && (
                <div className="text-green-600">
                    <h3>Data loaded successfully!</h3>
                    {/* Render your charts here */}
                </div>
            )}
        </div>
    );
}

The Engineering ROI

State machines completely eliminate "impossible states." You never have to write complex validation logic to check if a user is trying to submit a form that is already in a loading state; the machine simply ignores the "SUBMIT" event if it is currently in the "loading" state. It forces developers to map out the entire lifecycle of a component before writing the UI, resulting in bulletproof, enterprise-grade React applications.