Gestionar flujos de trabajo asíncronos complejos mediante banderas booleanas aisladas (isLoading, isSubmitting, hasError, canRetry) conduce inevitablemente a una explosión combinatoria de estados inválidos. En aplicaciones de interfaz de usuario tradicionales, 4 banderas independientes generan 16 estados potenciales, la mayoría de los cuales son lógicamente imposibles o defectuosos (por ejemplo, la coexistencia de isLoading === true e isSuccess === true).
La solución matemática a este problema reside en la teoría de máquinas de estados finitos (Finite State Machines, FSM) y los diagramas de estado de Harel (Statecharts). XState v5 representa una reestructuración arquitectónica completa de la biblioteca construida sobre el modelo de actores (Actor Model).
En XState v4, el tipado requería una herramienta CLI externa en segundo plano (@xstate/cli) que generaba archivos auxiliares *.typegen.d.ts. En XState v5, esta pesada infraestructura fue eliminada por completo gracias a la inferencia de tipos nativa basada en la función setup(). Ahora, todo el tipado del contexto, eventos, actores, guardas (guards) y acciones (actions) es computado directamente por el compilador de TypeScript de forma síncrona sin necesidad de generadores externos.
+-------------------------------------------------------------------------+
| ARQUITECTURA DE INFERENCIA DE TIPOS EN XSTATE V5 |
+-------------------------------------------------------------------------+
| [setup({ types: {} as { context, events, input }, actors, actions })] |
| |--> Punto único para declarar los contratos de la máquina |
| |--> Inferencia automática de tipos sin archivos CLI typegen |
+-------------------------------------------------------------------------+
|
v
| [machine = setup(...).createMachine({ ... })] |
| |--> Verificación estricta de estados destino (states: { idle, etc }) |
| |--> Tipado de guard(({ context, event }) => boolean) |
| `-- Tipado de assign(({ context, event }) => nextContext) |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| [actor = createActor(machine, { input: { initialBudget: 100 } })] |
| |-- actor.start() |
| |-- actor.send({ type: 'SUBMIT', payload: 'data' }) |
| `-- actor.getSnapshot().matches({ active: 'processing' }) |
+-------------------------------------------------------------------------+En el paradigma de XState v5, cada aplicación o servicio independiente se modela como un sistema de actores que se comunican entre sí:
fromPromise, fromObservable, fromCallback, fromTransition).Flujo de interacción de actores en XState v5:
+-------------------------------------------------------------+
| Actor raíz: CheckoutMachine |
| |
| [Estado: validating] |
| | |
| | invoke: fromPromise(verifyCardApi) |
| v |
| +---------------------------------------------------------+ |
| | Actor hijo: CardVerifier (Promise) | |
| | Ejecuta la petición de red hacia la pasarela de pagos | |
| +---------------------------------------------------------+ |
| | |
| |-- onDone: assign(cardData) -> transición a [ready] |
| `-- onError: assign(error) -> transición a [error] |
+-------------------------------------------------------------+Disconnected -> Connecting -> Authenticating -> Connected -> Reconnecting gobernadas por retroceso exponencial.setup()#Construiremos una máquina de estados para un flujo de autenticación con control de reintentos y gestión de errores:
import { setup, assign, fromPromise } from 'xstate';
// 1. Definición de contratos de contexto y eventos
export interface UserSession {
readonly userId: string;
readonly email: string;
readonly role: 'admin' | 'user';
}
export interface AuthContext {
readonly session: UserSession | null;
readonly errorMessage: string | null;
readonly retryCount: number;
}
export type AuthEvent =
| { type: 'LOGIN'; readonly email: string; readonly pass: string }
| { type: 'LOGOUT' }
| { type: 'RETRY' };
export interface AuthInput {
readonly defaultRetryLimit: number;
}
// 2. Actor asíncrono para la autenticación
const loginService = fromPromise<
UserSession,
{ readonly email: string; readonly pass: string }
>(async ({ input }) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!response.ok) {
throw new Error(`[AuthError] Credenciales inválidas (${response.status})`);
}
return (await response.json()) as UserSession;
});
// 3. Configuración integral con setup() e inferencia de tipos
export const authMachine = setup({
types: {
context: {} as AuthContext,
events: {} as AuthEvent,
input: {} as AuthInput,
},
actors: {
loginService,
},
guards: {
canRetry: ({ context }, params: { limit: number }) =>
context.retryCount < params.limit,
},
actions: {
notifyAdmin: ({ context }) => {
console.warn(`[Alerta de seguridad] Límite de intentos superado: ${context.errorMessage}`);
},
},
}).createMachine({
id: 'authentication',
initial: 'idle',
context: ({ input }) => ({
session: null,
errorMessage: null,
retryCount: 0,
}),
states: {
idle: {
on: {
LOGIN: {
target: 'authenticating',
actions: assign({
errorMessage: () => null,
}),
},
},
},
authenticating: {
invoke: {
id: 'loginActor',
src: 'loginService',
input: ({ event }) => {
// TypeScript infiere con precisión que event aquí es de tipo LOGIN
if (event.type !== 'LOGIN') {
throw new Error('Se esperaba el evento LOGIN');
}
return { email: event.email, pass: event.pass };
},
onDone: {
target: 'authenticated',
actions: assign({
session: ({ event }) => event.output,
retryCount: () => 0,
}),
},
onError: {
target: 'failure',
actions: assign({
errorMessage: ({ event }) =>
event.error instanceof Error ? event.error.message : 'Error desconocido',
retryCount: ({ context }) => context.retryCount + 1,
}),
},
},
},
authenticated: {
on: {
LOGOUT: {
target: 'idle',
actions: assign({
session: () => null,
}),
},
},
},
failure: {
on: {
RETRY: [
{
guard: { type: 'canRetry', params: { limit: 3 } },
target: 'idle',
},
{
target: 'locked',
actions: 'notifyAdmin',
},
],
},
},
locked: {
type: 'final',
},
},
});En XState v5, el actor se instancia mediante createActor(), recibiendo la máquina y los parámetros input:
import { createActor } from 'xstate';
import { authMachine } from '../machines/authMachine';
export function initializeAuthSystem() {
// Inicialización del actor proveyendo el input obligatorio
const actor = createActor(authMachine, {
input: { defaultRetryLimit: 3 },
});
// Suscripción reactiva a las instantáneas de estado
actor.subscribe((snapshot) => {
console.log(`Estado actual: ${String(snapshot.value)}`);
// snapshot.matches() comprueba con rigor los identificadores de estado
if (snapshot.matches('authenticated')) {
console.log('Sesión iniciada con éxito:', snapshot.context.session?.email);
} else if (snapshot.matches('locked')) {
console.error('La cuenta ha sido bloqueada temporalmente.');
}
});
actor.start();
// Envío de eventos validados por el compilador
actor.send({
type: 'LOGIN',
email: '[email protected]',
pass: 'Secr3t!P@ssw0rd',
});
return actor;
}La interfaz o un WebSocket externo envía un objeto de evento estrictamente tipado al buzón de mensajes del actor.
El actor procesa los predicados guards. Si devuelven false, la transición se rechaza o se evalúa una rama alternativa.
Las funciones assign devuelven un nuevo objeto de contexto sin modificar el estado previo en memoria.
El actor entra en el nuevo estado destino, arranca los servicios asociados (invoke) y notifica a los suscriptores con un nuevo Snapshot.
import { setup, assign, fromCallback } from 'xstate';
export interface CountdownContext {
readonly remainingSeconds: number;
readonly initialSeconds: number;
}
export type CountdownEvent =
| { type: 'START' }
| { type: 'PAUSE' }
| { type: 'RESET' }
| { type: 'TICK' };
export interface CountdownInput {
readonly durationSeconds: number;
}
// Actor temporizador periódico basado en fromCallback
const timerService = fromCallback<CountdownEvent>(({ sendBack }) => {
const intervalId = setInterval(() => {
sendBack({ type: 'TICK' });
}, 1000);
return () => {
clearInterval(intervalId);
};
});
export const countdownMachine = setup({
types: {
context: {} as CountdownContext,
events: {} as CountdownEvent,
input: {} as CountdownInput,
},
actors: {
timerService,
},
}).createMachine({
id: 'countdown',
initial: 'idle',
context: ({ input }) => ({
remainingSeconds: input.durationSeconds,
initialSeconds: input.durationSeconds,
}),
states: {
idle: {
on: {
START: 'running',
RESET: {
actions: assign({
remainingSeconds: ({ context }) => context.initialSeconds,
}),
},
},
},
running: {
invoke: {
id: 'timerTicker',
src: 'timerService',
},
on: {
PAUSE: 'paused',
RESET: {
target: 'idle',
actions: assign({
remainingSeconds: ({ context }) => context.initialSeconds,
}),
},
TICK: [
{
guard: ({ context }) => context.remainingSeconds <= 1,
target: 'completed',
actions: assign({ remainingSeconds: () => 0 }),
},
{
actions: assign({
remainingSeconds: ({ context }) => context.remainingSeconds - 1,
}),
},
],
},
},
paused: {
on: {
START: 'running',
RESET: {
target: 'idle',
actions: assign({
remainingSeconds: ({ context }) => context.initialSeconds,
}),
},
},
},
completed: {
type: 'final',
},
},
});En XState v5, recurrir a la sintaxis previa con genéricos explícitos createMachine<Context, Event> impide la inferencia automática de acciones, actores e inputs, provocando que los parámetros en los callbacks se degraden a any.
// ❌ ANTIPATRÓN: Sintaxis obsoleta de v4
const badMachine = createMachine<Context, Event>({ ... });
// ✅ CORRECTO: Estándar moderno de XState v5 con setup()
const goodMachine = setup({
types: {
context: {} as Context,
events: {} as Event,
},
}).createMachine({ ... });El objeto context es estrictamente inmutable (congelado mediante Object.freeze). Cualquier asignación directa como context.count++ arrojará un error en modo estricto o provocará que el actor ignore la actualización de estado.
// ❌ ANTIPATRÓN: Mutación directa del contexto
actions: ({ context }) => {
context.retryCount += 1; // ERROR: Mutación de contexto congelado
}
// ✅ CORRECTO: Uso de la función assign
actions: assign({
retryCount: ({ context }) => context.retryCount + 1,
})Bajo el modelo de actores, la instancia devuelta por createActor(machine) se encuentra en estado inactivo. Enviar mensajes con actor.send() antes de invocar actor.start() provocará que los eventos se descarten silenciosamente.
// ❌ ANTIPATRÓN: Enviar eventos a un actor no iniciado
const actor = createActor(machine);
actor.send({ type: 'EVENT' }); // ¡EL EVENTO SE PIERDE!
// ✅ CORRECTO: Iniciar el actor antes del envío de mensajes
const actor = createActor(machine);
actor.start();
actor.send({ type: 'EVENT' });En invoke.input: ({ event }) => ..., la propiedad event es una unión discriminada de todos los eventos que pueden haber activado la transición hacia ese estado. Acceder a propiedades de un evento específico sin una comprobación previa provocará un error de TypeScript TS2339.
// ✅ CORRECTO: Comprobación discriminada del evento entrante
input: ({ event }) => {
if (event.type === 'LOGIN') {
return { email: event.email };
}
return { email: '' };
}Identifique los estados discretos del sistema (idle, loading, active, error) y los eventos desencadenantes de transiciones.
Declare las estructuras de context, events e input usando moldes de tipo (types: {} as { ... }).
Encapsule peticiones asíncronas en la sección actors de setup() para integrarlas con tipado estricto mediante invoke.
Configure transiciones, aplique guards para reglas de negocio y use assign para transformaciones de contexto.
Inicie el actor en su arquitectura o conéctelo a componentes React con el paquete @xstate/react (useActorRef / useSelector).