Volver

Máquinas de estado en React con XState

Las máquinas de estado son una forma poderosa de manejar la lógica compleja de las aplicaciones. En este artículo, exploraremos XState usando como ejemplo una aplicación real de reserva de vuelos.

¿Qué es XState?

XState es una biblioteca que implementa máquinas de estado finitas y statecharts en JavaScript. Permite modelar la lógica de tu aplicación de forma predecible y visual, facilitando el manejo de flujos complejos y reduciendo bugs.

Instalación

Para integrar XState en tu proyecto React, necesitas instalar dos paquetes:

npm install xstate @xstate/react
  • xstate: La biblioteca core que proporciona la funcionalidad de las máquinas de estado
  • @xstate/react: Los hooks de React para integrar XState con tus componentes

Inicialización de una Máquina de Estado

Veamos cómo se inicializa la máquina en nuestro ejemplo:

import { useMachine } from '@xstate/react'
import bookingMachine from '../Machines/bookingMachine'

export const BaseLayout = () => {
  const [state, send] = useMachine(bookingMachine)
  
  return (
    <div className='BaseLayout'>
      <Nav state={state} send={send} />
      <StepsLayout state={state} send={send}/>
    </div>
  );
}

El hook useMachine retorna dos valores:

  • state: El estado actual de la máquina, que contiene información sobre en qué estado se encuentra y su contexto
  • send: Una función para enviar eventos a la máquina y provocar transiciones

Conceptos Fundamentales

Estados (States)

Los estados representan las diferentes situaciones en las que puede estar tu aplicación. En nuestro ejemplo de reserva de vuelos:

const bookingMachine = createMachine({
  id: 'buy plane tickets',
  initial: 'initial',
  states: {
    initial: { /* ... */ },
    search: { /* ... */ },
    passengers: { /* ... */ },
    tickets: { /* ... */ },
  },
})

Tenemos cuatro estados principales: initial (bienvenida), search (búsqueda de destino), passengers (agregar pasajeros) y tickets (mostrar ticket).

Eventos (Events)

Los eventos son acciones que desencadenan cambios de estado. Se envían usando la función send:

const startBooking = () => {
  send('START');
};

En el código vemos eventos como START, CONTINUE, CANCEL, ADD, DONE, y FINISH.

Transiciones

Las transiciones definen cómo la máquina pasa de un estado a otro en respuesta a eventos.

Transiciones básicas (on)

initial: {
  on: {
    START: {
      target: 'search',
    },
  },
}

Cuando estamos en el estado initial y recibimos el evento START, la máquina transiciona al estado search.

Transiciones con acciones

search: {
  on: {
    CONTINUE: {
      target: 'passengers',
      actions: assign({
        selectedCountry: (_, event) => event.selectedCountry
      }),
    },
    CANCEL: {
      target: 'initial',
      actions: 'cleanContext',
    },
  },
}

Al recibir CONTINUE, no solo cambiamos de estado, sino que también ejecutamos una acción para guardar el país seleccionado en el contexto.

Transiciones condicionales

passengers: {
  on: {
    DONE: {
      target: 'tickets',
      cond: 'moreThanOnePassenger',
    },
  },
}

La transición solo ocurre si se cumple la condición moreThanOnePassenger, que verifica que hay al menos un pasajero:

guards: {
  moreThanOnePassenger: (context) => context.passengers.length > 0
}

Contexto (Context)

El contexto es un objeto que almacena datos persistentes a través de los estados:

context: {
  passengers: [],
  selectedCountry: '',
  countries: [],
  error: '',
}

Se puede modificar usando la función assign:

ADD: {
  target: 'passengers',
  actions: assign(
    (context, event) => context.passengers.push(event.newPassenger)
  ),
}

Acciones (Actions)

Las acciones son efectos secundarios que ocurren durante las transiciones. Pueden ser definidas inline o como acciones nombradas:

actions: {
  cleanContext: assign({
    selectedCountry: '',
    passengers: [],
  }),
}

Esta acción se ejecuta cuando cancelamos o finalizamos el proceso de reserva.

Transiciones Avanzadas

onDone y onError

Estas transiciones se usan con servicios invocados (promesas, callbacks, etc.):

loading: {
  invoke: {
    id: 'getCountries',
    src: () => fetchCountries,
    onDone: {
      target: 'success',
      actions: assign({
        countries: (_, event) => event.data,
      }),
    },
    onError: {
      target: 'failure',
      actions: assign({
        error: 'Fallo el request',
      }),
    },
  }
}
  • onDone: Se ejecuta cuando la promesa se resuelve exitosamente. Los datos retornados están en event.data
  • onError: Se ejecuta cuando la promesa es rechazada

Transiciones por Tiempo (after)

Permiten ejecutar transiciones automáticas después de un tiempo determinado:

tickets: {
  after: {
    5000: {
      target: 'initial',
      actions: 'cleanContext',
    }
  },
}

Después de 5 segundos en el estado tickets, automáticamente volvemos al estado inicial y limpiamos el contexto.

Máquinas de Estado Jerárquicas (Estados Hijos)

Las máquinas jerárquicas permiten anidar estados dentro de otros estados, creando subestados. En nuestro ejemplo:

const fillCountries = {
  initial: 'loading',
  states: {
    loading: { /* ... */ },
    success: {},
    failure: {
      on: {
        RETRY: { target: 'loading' },
      },
    },
  }
}

search: {
  on: { /* transiciones del estado padre */ },
  ...fillCountries,  // Estados hijos
}

Cuando entramos al estado search, automáticamente entramos también a su estado hijo inicial loading. Esto permite:

  • Organización: Agrupar estados relacionados
  • Reutilización: Los estados hijos pueden ser compartidos
  • Encapsulación: Los estados hijos manejan su propia lógica

El estado completo sería algo como search.loading, search.success, o search.failure. Puedes verificar estados anidados con:

state.matches('search.loading')
// o
state.matches({ search: 'loading' })

Máquinas de Estado en Paralelo

Los estados paralelos permiten que múltiples estados existan simultáneamente. Aunque nuestro ejemplo no los usa, se definen así:

const parallelMachine = createMachine({
  type: 'parallel',
  states: {
    upload: {
      initial: 'idle',
      states: {
        idle: {},
        uploading: {},
        success: {},
      }
    },
    download: {
      initial: 'idle',
      states: {
        idle: {},
        downloading: {},
        success: {},
      }
    }
  }
});

En este caso, tanto upload como download pueden estar activos al mismo tiempo, permitiendo cargas y descargas simultáneas. Cada región paralela mantiene su propio estado independiente.

Servicios (invoke)

Los servicios permiten ejecutar efectos secundarios de larga duración. XState soporta varios tipos:

Promesas

invoke: {
  id: 'getCountries',
  src: () => fetchCountries,
  onDone: {
    target: 'success',
    actions: assign({
      countries: (_, event) => event.data,
    }),
  },
  onError: {
    target: 'failure',
    actions: assign({
      error: 'Fallo el request',
    }),
  },
}

Callbacks

invoke: {
  id: 'timeout',
  src: (context, event) => (callback) => {
    const timeoutId = setTimeout(() => {
      callback('TIMEOUT');
    }, 3000);
    
    return () => clearTimeout(timeoutId);
  }
}

Observables

invoke: {
  id: 'observable',
  src: () => interval(1000),
  onNext: {
    actions: assign({
      counter: (context, event) => event.data
    })
  }
}

Invocar Otras Máquinas

invoke: {
  id: 'childMachine',
  src: childMachine,
  onDone: {
    target: 'completed'
  }
}

Validaciones (Guards)

Las validaciones o guards permiten condicionar las transiciones. En nuestro ejemplo:

passengers: {
  on: {
    DONE: {
      target: 'tickets',
      cond: 'moreThanOnePassenger',
    },
  },
}

La definición del guard:

guards: {
  moreThanOnePassenger: (context) => context.passengers.length > 0
}

Esto asegura que no podemos ver el ticket hasta que hayamos agregado al menos un pasajero. Si intentamos hacer la transición sin cumplir la condición, la máquina permanece en el estado actual.

Los guards también pueden ser inline:

SUBMIT: {
  target: 'submitted',
  cond: (context, event) => event.value.length > 0
}

Ventajas de Usar XState

  1. Código Predecible: Los flujos están claramente definidos
  2. Fácil Depuración: Puedes visualizar tu máquina con herramientas como XState Visualizer
  3. Prevención de Estados Imposibles: Solo puedes estar en estados válidos
  4. Testing Simplificado: Es fácil probar cada transición
  5. Documentación Viva: La máquina sirve como documentación del flujo

Conclusión

XState transforma la manera de manejar estado complejo en React. Aunque tiene una curva de aprendizaje inicial, los beneficios en mantenibilidad, testabilidad y claridad del código son invaluables. El ejemplo de reserva de vuelos demuestra cómo modelar un flujo completo con estados, transiciones, validaciones y efectos secundarios de forma elegante y escalable.

Te animo a experimentar con XState en tu próximo proyecto. Empieza con flujos simples y gradualmente incorpora características más avanzadas como estados paralelos o máquinas jerárquicas complejas.

Anexos

Avatar

Autor

Elan Francisco P. Asprilla
Desarrollador Frontend

Artículos relacionados

Banner Contact (Hidrotecno)

El componente “Banner Contacto” es...

Banner Text (Hidrotecno)

El componente “Banner Texto” es una...

¿Qué son las taxonomías en Drupal?

En Drupal, las taxonomías son un sistema de...