wandres.dev
MÁQUINAS Y DATOS · combinar con el servidor

La tentación de modelar el fetch como máquina

El statechart de cuatro estados —inactivo, cargando, éxito, fallo— es el hola mundo de XState y, por eso mismo, el fragmento más copiado sin examinar de todo el ecosistema. Esta lección separa lo que la máquina aporta de verdad, cuando el problema tiene pasos encadenados con reglas, de lo que simplemente duplica: la deduplicación por clave, la revalidación en foco, la invalidación cruzada y la recolección de basura que cualquier cache de datos moderna ya resuelve. El criterio que emerge no es estilístico sino de propiedad: quién es el dueño marginal del dato y quién del flujo.

⏱ 17 min

Casi todos los tutoriales de máquinas de estado empiezan por el mismo ejemplo: una petición con cuatro estados dibujada como un grafo limpísimo donde ningún booleano puede contradecir a otro. El ejemplo es pedagógicamente impecable y arquitectónicamente engañoso, porque enseña invoke usando justo el problema que en 2026 ya tiene dueño: la lectura de un recurso remoto pertenece a una cache, no a un grafo. La pregunta que abre este nivel no es si puedes modelar un fetch como máquina —puedes, y queda bonito en el inspector— sino qué gobierna esa máquina que la cache no gobernaba ya mejor. Responderla exige distinguir dos cosas que el hola mundo confunde deliberadamente: el ciclo de vida de un dato y el flujo de una interacción.

🎯 Al terminar esta lección sabrás
  • Reconocer el statechart canónico del fetch y por qué es el ejemplo de todos los tutoriales.
  • Distinguir un flujo con pasos y reglas de una simple lectura de un recurso remoto.
  • Enumerar lo que una cache de datos ya resuelve y que ninguna máquina de una sola petición aporta.
  • Aplicar la prueba de la propiedad marginal antes de escribir el primer createMachine.

El hola mundo que todo el mundo copia

La máquina canónica cabe en una pantalla y hace exactamente lo que promete: elimina por construcción el cuarteto de booleanos correlacionados que en cualquier código sin estructura acaban permitiendo cargando y error a la vez.

import { setup, fromPromise, assign } from 'xstate'

export const recurso = setup({
  types: {
    context: {} as { id: string; datos: unknown; error: unknown },
    events: {} as { type: 'CARGAR' } | { type: 'REINTENTAR' },
  },
  actors: {
    leer: fromPromise(async ({ input }: { input: { id: string } }) => {
      const res = await fetch(`/api/usuarios/${input.id}`)
      if (!res.ok) throw new Error(String(res.status))
      return res.json()
    }),
  },
}).createMachine({
  initial: 'inactivo',
  context: { id: '', datos: null, error: null },
  states: {
    inactivo: { on: { CARGAR: 'cargando' } },
    cargando: {
      invoke: {
        src: 'leer',
        input: ({ context }) => ({ id: context.id }),
        onDone: { target: 'exito', actions: assign({ datos: ({ event }) => event.output }) },
        onError: { target: 'fallo', actions: assign({ error: ({ event }) => event.error }) },
      },
    },
    exito: { on: { CARGAR: 'cargando' } },
    fallo: { on: { REINTENTAR: 'cargando' } },
  },
})

Todo lo que esta máquina hace bien es cierto: los estados son excluyentes, la transición de error tiene destino explícito, el actor se cancela solo al salir de cargando y el diagrama documenta el flujo sin comentarios. El problema no está en lo que hace, sino en lo que no hace y en lo que hace por segunda vez. No deduplica: dos componentes que monten esta máquina lanzarán dos peticiones idénticas. No comparte: el dato vive en el contexto de un actor y muere con él. No revalida: si la pestaña vuelve del segundo plano tras media hora, exito sigue diciendo éxito con datos de hace media hora. Y no recolecta nada: no hay tiempo de vida, no hay clave, no hay noción de frescura.

Hay además un coste que no aparece en el diagrama y se paga en la arquitectura: cada máquina de este tipo se convierte en un dueño nuevo del mismo dato. Si tres pantallas necesitan el perfil del usuario y cada una monta su máquina, el sistema tiene tres verdades sobre el perfil que solo coinciden por casualidad, y ninguna sabe de la existencia de las otras dos. El grafo, que se dibujó para eliminar estados imposibles dentro de un actor, ha introducido sin querer una clase de inconsistencia mucho peor entre actores: la que ningún tipo detecta y ningún inspector muestra, porque cada máquina, por separado, es impecable.

ℹ️
El ejemplo enseña invoke, no arquitectura

El fetch es el hola mundo de los statecharts por una razón didáctica excelente: es el efecto asíncrono más familiar que existe y permite explicar invoke, onDone, onError y la cancelación automática sin inventar un dominio. Pero un ejemplo elegido por su valor pedagógico no es una recomendación de diseño. Confundir ambas cosas es la causa más frecuente de máquinas que compiten con la capa de datos en vez de coordinarla.

Lo que una cache ya hacía por ti

Antes de decidir si tu petición merece una máquina, conviene tener presente el inventario completo de lo que una cache de estado de servidor —TanStack Query, SWR, o el equivalente de tu framework— resuelve de serie y que tendrías que reimplementar a mano dentro del grafo.

Responsabilidad Máquina de una petición Cache de datos
Estados imposibles los elimina por construcción los elimina con una unión discriminada
Deduplicación entre consumidores inexistente por clave, automática
Revalidación en foco o reconexión la escribes tú política declarativa
Servir el dato viejo mientras refresca no aplica revalidación en segundo plano
Invalidación tras una mutación mensajes entre actores a mano por clave o por predicado
Recolección del dato inactivo ninguna tiempo de vida configurable
Paginación e infinito estados a medida primitivas dedicadas

Ninguna fila de la derecha es opcional en una aplicación real; simplemente, cuando no la tienes, alguien la escribe a mano peor. Y ninguna de ellas es lo que una máquina de estado sabe hacer especialmente bien, porque todas son problemas de identidad y frescura de datos, no de orden y legalidad de transiciones. La máquina razona sobre en qué modo estoy y qué puedo hacer ahora; la cache razona sobre qué dato es este, cuándo caducó y quién más lo está mirando. Son ejes ortogonales, y confundirlos hace que cada capa haga mal el trabajo de la otra.

La fila de la recolección merece un comentario aparte porque es la que menos se echa de menos y más se paga a largo plazo. Un dato guardado en el contexto de una máquina no caduca nunca mientras el actor viva y desaparece del todo en cuanto muere: dos comportamientos extremos y ninguno deseable. La cache ofrece el término medio que las aplicaciones reales necesitan, que es un dato que sobrevive a la pantalla que lo pidió pero no eternamente, con una vida útil que se puede afinar por recurso según cuánto cueste traerlo y cuánto importe su frescura.

📝
Esto no es un argumento contra las máquinas

El inventario anterior no dice que las máquinas sean malas para el asincronismo; dice que la lectura de recursos ya tiene una herramienta especializada. Las máquinas siguen siendo insuperables para el orden, la legalidad de las transiciones, la cancelación estructurada y la coordinación de varios efectos. La conclusión práctica no es usar menos statecharts, sino ponerlos donde su ventaja es exclusiva y no donde otra capa lleva años puliendo la misma solución.

💡
La prueba del segundo consumidor

Antes de envolver una lectura en una máquina, hazte una pregunta barata: si mañana otra pantalla necesitara este mismo dato, ¿cómo lo obtendría? Si la respuesta honesta es montando otra vez esta máquina o recibiéndolo por props desde un antepasado común, el dato no está donde debe. Si la respuesta es pidiéndolo por su clave, sin saber nada de este flujo, entonces la capa de datos ya es su dueña y la máquina puede limitarse a coordinar el orden.

⚠️
La duplicación no se ve el primer día

Una máquina que guarda su dato en el contexto parece inofensiva mientras hay un solo consumidor. El coste aparece cuando el segundo componente necesita el mismo recurso: o bien monta su propia máquina —y ahora hay dos copias que divergen— o bien recibe el dato por props desde arriba y has reinventado el prop drilling que la cache eliminaba con una clave. La divergencia entre copias es un fallo silencioso: nadie lanza un error, simplemente dos partes de la pantalla muestran versiones distintas de la verdad.

Pasos frente a lecturas: el criterio de propiedad

La máquina empieza a ganar en cuanto el problema deja de ser una lectura y pasa a ser un procedimiento. Un procedimiento tiene varias peticiones encadenadas, decisiones intermedias, puntos de vuelta atrás y efectos que no son idempotentes. Eso no es cache: es protocolo, y el protocolo es exactamente lo que un statechart expresa mejor que ninguna otra herramienta.

🌐

Es una lectura

Un recurso identificado por una clave, idempotente, que varios sitios quieren mostrar. Necesita frescura y compartición, no un grafo. Dueño natural: la cache.

🪜

Es un procedimiento

Reservar, confirmar el pago, verificar con un tercero, emitir el billete. Pasos con orden, cancelación y compensación. Dueño natural: la máquina.

📤

Es una subida por partes

Trocear, subir cada parte, reanudar tras un fallo, cerrar la carga multiparte. Tiene ciclo de vida propio y no cabe en una clave de cache.

🔁

Es una escritura con vuelta

Enviar, esperar confirmación externa, reconciliar y revalidar lo que la escritura invalidó. La máquina orquesta y luego avisa a la cache.

Las dos primeras tarjetas marcan los polos y las dos últimas ocupan la zona donde vive la mayoría de los casos reales, que rara vez son puros. Una subida por partes y una escritura con reconciliación son procedimientos con datos dentro, y ahí la respuesta correcta no es elegir capa sino repartir: la máquina lleva la secuencia y la cache lleva los recursos que esa secuencia lee y ensucia. Ese reparto es el asunto de la lección siguiente.

La prueba práctica que resuelve casi todos los casos dudosos es la de la propiedad marginal: pregunta quién es el dueño del dato una vez la petición terminó. Si la respuesta es cualquier consumidor que conozca la clave, el dato es de la cache y la máquina solo debería conocer su identificador. Si la respuesta es este flujo y nadie más, porque el valor solo tiene sentido dentro de la secuencia en curso, entonces vive legítimamente en el contexto de la máquina y morirá con ella, que es justo lo que quieres.

flowchart TD
A[el problema tiene varios pasos con reglas?] -->|no| B[es una lectura: cache con clave]
A -->|si| C[los pasos dependen unos de otros?]
C -->|no| D[varias lecturas: varias claves de cache]
C -->|si| E[hay cancelacion reintento o vuelta atras?]
E -->|no| F[una funcion async secuencial basta]
E -->|si| G[maquina para el flujo y cache para los datos]
style B fill:#a6e3a1,color:#11111b
style D fill:#89dceb,color:#11111b
style G fill:#cba6f7,color:#11111b

Fíjate en la hoja intermedia, la que casi nadie dibuja: varios pasos que no se condicionan entre sí y sin cancelación no son una máquina, son una función asíncrona con tres await seguidos. Subir a un statechart ahí no elimina ningún estado imposible, porque entre paso y paso no había ninguna regla que romper. El grafo solo empieza a pagar cuando existe la posibilidad de ir hacia atrás, de abortar a mitad o de bifurcar según el resultado intermedio.

Conviene decir también qué no implica este criterio, porque la lectura perezosa lleva a un extremo tan malo como el que combate. No se trata de que las máquinas y el estado de servidor sean incompatibles: se trata de que la petición no es el motivo por el que existe la máquina. Un flujo de alta de cliente con seis pasos, validación externa y posibilidad de retroceso merece su statechart aunque cada paso hable con la red, y ese statechart hará peticiones constantemente. Lo que no debe hacer es guardarlas, fecharlas ni decidir si siguen siendo válidas.

Las cuatro lecciones restantes de este nivel desarrollan justamente esa convivencia: primero la frontera concreta entre el grafo y la cache, con los puentes que la implementan; después los reintentos, que sí son estructura del flujo y no ruido técnico; luego los estados de carga compuestos, donde la máquina expresa lo que un booleano jamás pudo; y por último el catálogo de señales que avisan de que el grafo empezó a hacer el trabajo de la capa de datos.

El fetch no es un flujo: es una lectura con latencia

La confusión de raíz —la que produce la mitad de las máquinas mal puestas de este ecosistema— es tratar la latencia como si fuera estructura. Que una operación tarde no la convierte en un proceso con fases; solo la convierte en una lectura que además tiene un intervalo durante el cual no sabes el resultado. Los cuatro estados del hola mundo no describen el dominio, describen tu ignorancia temporal sobre un valor: inactivo es aún no he preguntado, cargando es he preguntado y no sé, éxito es ya sé, fallo es no pude saber. Eso no es un protocolo del negocio, es una epistemología del cliente, y la epistemología de un valor remoto se resuelve mucho mejor con una cache que sepa de identidad, frescura y compartición que con un grafo que solo sabe de orden. La máquina de estado es la herramienta correcta cuando existen reglas sobre qué es legal hacer después de qué, y esas reglas emanan del dominio, no del hecho de que la red tarde. Por eso la prueba definitiva no se hace mirando el código sino el enunciado: si al describir el problema en voz alta dices primero esto, luego aquello, y si falla aquí se vuelve allá, tienes un statechart en las manos; si dices necesito estos datos para pintar la pantalla, tienes una consulta con latencia y el grafo sobra. Toda esta lección se reduce a esa diferencia gramatical: los procedimientos se conjugan en secuencia, los datos se declinan en identidad.

⚔️ Audita tus máquinas de petición
  1. Localiza en tu código una máquina cuyo único propósito sea envolver una petición de lectura y cuenta cuántos de sus estados existen por el dominio y cuántos por la latencia.
  2. Recorre el inventario de la tabla y marca cuántas de las siete responsabilidades estás reimplementando a mano dentro del grafo.
  3. Aplica la prueba de la propiedad marginal a cada dato que esa máquina guarda en su contexto: decide si el dueño es la cache o el flujo.
  4. Busca un caso donde dos componentes monten la misma máquina y comprueba en la pestaña de red cuántas peticiones idénticas se disparan.
  5. Elige un procedimiento real de tu aplicación —pago, alta, subida por partes— y dibuja sus pasos como estados sin escribir todavía código.
  6. Para ese procedimiento, escribe qué información pertenece al contexto de la máquina y cuál debería seguir viviendo en la cache bajo una clave.