Blog Post
4 min read

State Machines vs. React Query vs. Local State

State solutions are tools. Each solves a different problem.

Published on March 26, 2026

State solutions are tools. Each solves a different problem.

Pick the wrong tool and you will be fighting complexity. Pick the right tool and the code almost writes itself.

Use Local State When

Local state is for ephemeral, isolated data:

  • Toggle: const [isOpen, setIsOpen] = useState(false) for a modal or menu.
  • Form input: const [name, setName] = useState('') for text fields, checkboxes, selects.
  • Tab selection: const [activeTab, setActiveTab] = useState('details') for tab groups.

Characteristics:

  • The lifecycle is trivial. Data exists for the duration of the component (or while a modal is open).
  • No server involved.
  • No cross-component synchronization needed.
  • Failures are local (wrong tab selected; no big deal).

Example:

function ProductDetails() {
  const [activeTab, setActiveTab] = useState<'description' | 'reviews' | 'specs'>('description');

  return (
    <div>
      <div role="tablist">
        {['description', 'reviews', 'specs'].map((tab) => (
          <button
            key={tab}
            role="tab"
            aria-selected={activeTab === tab}
            onClick={() => setActiveTab(tab as any)}
          >
            {tab.charAt(0).toUpperCase() + tab.slice(1)}
          </button>
        ))}
      </div>
      <div>
        {activeTab === 'description' && <p>Product description...</p>}
        {activeTab === 'reviews' && <p>Customer reviews...</p>}
        {activeTab === 'specs' && <p>Specifications...</p>}
      </div>
    </div>
  );
}

Use React Query When

React Query is for server data that needs to stay in sync:

  • Fetched data: User list, product details, blog posts.
  • Data that changes: You need to refetch, invalidate, or sync across tabs.
  • Mutations: Form submissions, deletions, updates.

Characteristics:

  • Data lives on the server; the client is a view.
  • Caching, retries, and refetch are complex; React Query handles them.
  • Optimistic updates and rollback matter.
  • Failures happen (network, server errors); you need recovery.

Example:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

function UsersList() {
  const queryClient = useQueryClient();

  // Fetch users
  const { data: users, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: async () => {
      const res = await fetch('/api/users');
      if (!res.ok) throw new Error('Failed to fetch users');
      return res.json();
    }
  });

  // Delete a user
  const deleteMutation = useMutation({
    mutationFn: async (userId: string) => {
      const res = await fetch(`/api/users/${userId}`, { method: 'DELETE' });
      if (!res.ok) throw new Error('Failed to delete');
      return res.json();
    },
    onSuccess: () => {
      // Refetch the list after successful delete
      queryClient.invalidateQueries({ queryKey: ['users'] });
    }
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          {user.name}
          <button onClick={() => deleteMutation.mutate(user.id)}>
            {deleteMutation.isPending ? 'Deleting...' : 'Delete'}
          </button>
        </li>
      ))}
    </ul>
  );
}

Use State Machines When

State machines are for explicit, complex flows:

  • Wizards: Multi-step forms with validation, conditional steps, and "go back" logic.
  • Payment flows: Idle → Checking → Processing → Success/Error.
  • Uploads: Idle → Uploading → Complete/Failed, with pause/resume.
  • Data fetching with guardrails: Idle → Loading → Success/Error, with retries.

Characteristics:

  • The flow has explicit states and transitions.
  • Some transitions are invalid (you cannot submit a form until step 3 is complete).
  • Recovery paths are defined (retry on error, reset, etc.).
  • You want visual documentation of the flow.

Example with XState:

import { createMachine, interpret } from 'xstate';

const checkoutMachine = createMachine({
  initial: 'idle',
  states: {
    idle: {
      on: { CHECKOUT: 'cart_review' }
    },
    cart_review: {
      on: { PROCEED: 'shipping', CANCEL: 'idle' }
    },
    shipping: {
      on: { SELECT_ADDRESS: 'payment', BACK: 'cart_review' }
    },
    payment: {
      on: {
        PAY: { target: 'processing', cond: 'hasBillingAddress' },
        BACK: 'shipping'
      }
    },
    processing: {
      after: {
        3000: [
          { target: 'success', cond: 'paymentSucceeded' },
          { target: 'error' }
        ]
      }
    },
    success: {
      on: { COMPLETE: 'idle' }
    },
    error: {
      on: { RETRY: 'payment' }
    }
  }
});

// Use in React
import { useMachine } from '@xstate/react';

function Checkout() {
  const [state, send] = useMachine(checkoutMachine);

  return (
    <div>
      <p>Current step: {state.value}</p>

      {state.matches('cart_review') && (
        <>
          <p>Review your cart</p>
          <button onClick={() => send('PROCEED')}>Proceed</button>
        </>
      )}

      {state.matches('payment') && (
        <>
          <p>Enter payment details</p>
          <button onClick={() => send('PAY')}>Pay</button>
        </>
      )}

      {state.matches('processing') && <p>Processing...</p>}

      {state.matches('success') && (
        <>
          <p>✓ Order placed!</p>
          <button onClick={() => send('COMPLETE')}>Done</button>
        </>
      )}

      {state.matches('error') && (
        <>
          <p>Payment failed</p>
          <button onClick={() => send('RETRY')}>Retry</button>
        </>
      )}
    </div>
  );
}

Decision Matrix

Problem Tool Why
Modal open/closed Local State Trivial lifecycle, isolated
Form inputs (name, email) Local State Ephemeral, local validation
User list from API React Query Server data, caching, refetch
Form submission React Query (mutation) Server write, retry, optimism
Multi-step wizard State Machine Explicit states, recovery
Payment flow State Machine Defined flow, guard clauses
Tab selection Local State UI state, isolated
Shopping cart (from server) React Query Server sync, add/remove items

Avoid Over-Engineering

The most common mistake is reaching for a state machine when local state would do.

A toggle does not need a state machine. A dropdown does not need XState.

Complexity pays for itself only when you have genuine complexity: conditional flows, guard clauses, recovery paths.