Máquina y TanStack Query: la división del trabajo
Cuando el flujo justifica una máquina y los datos siguen siendo de servidor, la respuesta no es elegir una capa sino repartir el mando con una frontera explícita: la máquina gobierna el orden de los pasos, la librería gobierna la identidad y la frescura de los datos. Esta lección desarrolla los tres puentes posibles entre ambas —el actor que invoca a la cache, el actor observador y el componente como pegamento—, fija qué información puede cruzar la frontera y cuál nunca debe, y explica por qué copiar el dato al contexto es la forma más rápida de perder las dos garantías a la vez.
Aceptado que un flujo con pasos merece una máquina y que un recurso remoto merece una cache, queda el problema interesante: casi todo flujo real necesita datos de servidor a mitad de camino. La tentación de resolverlo eligiendo un único bando —o todo en la máquina, o todo en la librería— produce arquitecturas que sufren por lados opuestos. La solución no es elegir, es repartir, y el reparto tiene una línea nítida que se puede enunciar en una frase: la máquina decide cuándo y en qué orden ocurren las cosas, TanStack Query decide qué dato es este, si sigue fresco y quién más lo está mirando. Todo lo que sigue es el desarrollo técnico de esa frase y, sobre todo, de lo que ocurre cuando alguien la ignora.
- Fijar la frontera entre gobierno del flujo y gobierno de la cache con un criterio operativo.
- Implementar el puente principal: un actor
fromPromiseque delega enfetchQuery. - Conocer los otros dos puentes —actor observador y componente pegamento— y sus costes.
- Decidir qué cruza la frontera: claves e identificadores sí, copias del dato no.
Quién manda sobre qué
La división del trabajo se vuelve mecánica en cuanto clasificas cada decisión según de qué depende su respuesta. Si depende de dónde está el usuario en el procedimiento, manda la máquina. Si depende de qué recurso es y de cuándo se leyó por última vez, manda la cache.
| Decisión | Quién manda | Por qué |
|---|---|---|
| En qué paso estamos | máquina | es orden, no datos |
| Si esta transición es legal | máquina | es protocolo del dominio |
| Cuándo lanzar la lectura | máquina | la entrada al estado la dispara |
| Si hace falta ir a la red | cache | depende de la frescura de la clave |
| Si dos peticiones son la misma | cache | la identidad la da la clave |
| Cuánto vive el dato leído | cache | es ciclo de vida del recurso |
| Qué se invalida tras escribir | ambas | la máquina ordena, la cache ejecuta |
La última fila es la interesante y la que más equipos resuelven mal. Tras una escritura con éxito, saber qué quedó obsoleto es conocimiento del dominio, y el dominio lo modela la máquina; pero ejecutar esa obsolescencia —marcar claves, avisar a los observadores, decidir si refrescar ya o al próximo montaje— es trabajo de la cache. La máquina emite la orden, la librería la cumple. Cuando la máquina intenta además cumplirla, empieza el anti-patrón que cierra este nivel.
No hay una capa por encima de la otra. La máquina no envuelve a la cache ni la cache alimenta a la máquina: son dos autoridades sobre dimensiones distintas del mismo problema. Piensa en un director de escena y un almacén de utilería. El director decide el orden de las escenas y cuándo entra cada objeto; el almacén sabe qué objetos existen, cuáles están gastados y cuáles comparten varias escenas. Ninguno de los dos puede hacer bien el trabajo del otro, y el desastre llega cuando el director empieza a guardar copias privadas de la utilería en su camerino.
El puente principal: la máquina invoca a la cache
El patrón que resuelve la mayoría de los casos es también el más simple: el actor invocado por la máquina no llama a fetch, llama a la cache. Así la entrada al estado sigue disparando la lectura —la máquina conserva el mando del cuándo— pero la deduplicación, la frescura y la compartición siguen siendo de la librería.
import { QueryClient } from '@tanstack/query-core'
import { setup, fromPromise, assign } from 'xstate'
export const qc = new QueryClient()
const leerPerfil = fromPromise(({ input, signal }: { input: { id: string }; signal: AbortSignal }) =>
qc.fetchQuery({
queryKey: ['perfil', input.id],
queryFn: () => fetch(`/api/perfil/${input.id}`, { signal }).then((r) => r.json()),
staleTime: 30_000,
})
)
Dos detalles hacen que este puente sea correcto y no una simple indirección. El primero es signal: XState cancela el actor al salir del estado y ese aborto debe llegar a la petición real, o tendrás peticiones huérfanas resolviendo contra un flujo que ya avanzó. El segundo es staleTime: si el dato de esa clave sigue fresco, fetchQuery resuelve desde memoria sin tocar la red, de modo que un flujo que revisita el mismo paso no repite trabajo. La máquina no se entera de esa optimización, y esa ignorancia es exactamente la propiedad que buscas.
const maquina = setup({
types: { context: {} as { id: string; borrador: Borrador | null } },
actors: { leerPerfil },
actions: {
invalidarPerfil: ({ context }) => {
qc.invalidateQueries({ queryKey: ['perfil', context.id] })
},
},
}).createMachine({
initial: 'revisando',
context: { id: '', borrador: null },
states: {
revisando: {
invoke: { src: 'leerPerfil', input: ({ context }) => ({ id: context.id }), onDone: 'editando', onError: 'fallo' },
},
editando: { on: { GUARDAR: 'guardando' } },
guardando: {
invoke: { src: 'guardarPerfil', onDone: { target: 'listo', actions: 'invalidarPerfil' }, onError: 'fallo' },
},
listo: { type: 'final' },
fallo: { on: { REINTENTAR: 'revisando' } },
},
})
Observa lo que el contexto guarda y lo que no. Guarda id, que es identidad y le pertenece al flujo, y borrador, que es estado de cliente en curso y no existe todavía en el servidor. No guarda el perfil leído: ese dato vive bajo su clave y cualquier componente lo obtiene con una consulta, sin pasar por la máquina ni por props.
ensureQueryData devuelve lo que haya en cache si existe, y solo va a la red si no hay nada; fetchQuery respeta staleTime y refresca si el dato caducó. Elige según la intención del paso: para pintar cuanto antes una pantalla intermedia sirve el primero, pero para un paso cuya corrección depende de leer el estado actual del servidor —comprobar saldo antes de cobrar— usa el segundo con staleTime en cero, porque ahí la frescura es un requisito del dominio y no una preferencia de rendimiento.
Los otros puentes y sus costes
El actor que invoca a la cache resuelve el caso de lectura puntual, pero hay dos situaciones donde no basta: cuando la máquina debe reaccionar a cambios posteriores del dato y cuando la máquina vive en la UI y prefieres no acoplarla al cliente de consultas. Las dos primeras tarjetas son esos puentes alternativos; las dos últimas, las piezas que los mantienen sanos.
Actor observador
Un fromCallback suscrito a un observador de la consulta que reenvía cada actualización como evento. La máquina reacciona a datos que cambian por debajo, no solo al resultado de su propia lectura.
Componente pegamento
El componente usa el hook de consulta y el hook de la máquina, y traduce datos en eventos. Mantiene la máquina pura y testeable, a cambio de vigilar los efectos para no entrar en bucle.
Orden de invalidación
La máquina no invalida: emite el evento que dice qué quedó obsoleto y una capa fina lo traduce a llamadas de cache. El grafo permanece independiente de la librería concreta.
Puente falso en test
Como todos los puentes son actores o acciones nombradas, en las pruebas se sustituyen con provide y la máquina se verifica sin red ni cliente de consultas.
import { fromCallback } from 'xstate'
import { QueryObserver } from '@tanstack/query-core'
const observarPedido = fromCallback(({ input, sendBack }: any) => {
const observer = new QueryObserver(qc, { queryKey: ['pedido', input.id] })
return observer.subscribe((r) => {
if (r.isSuccess) sendBack({ type: 'PEDIDO_ACTUALIZADO', datos: r.data })
if (r.isError) sendBack({ type: 'PEDIDO_FALLO', error: r.error })
})
})
El actor observador convierte la cache en una fuente de eventos, que es la forma en que una máquina sabe escuchar. Es el puente adecuado cuando el flujo debe responder a cambios que no provocó —una revalidación en foco que descubre que el pedido ya fue pagado en otro dispositivo—, y es también el más caro: cada actualización entra como evento y debes decidir en qué estados es legal recibirla. Un evento de datos que llega en mitad de un paso crítico y no está declarado en ese estado se ignora, y ese silencio es una decisión de diseño que conviene tomar a propósito.
sequenceDiagram participant U as usuario participant M as maquina participant Q as cache de datos participant S as servidor U->>M: evento CONFIRMAR M->>Q: pedir la clave del recurso alt clave fresca Q-->>M: dato desde memoria sin red else clave obsoleta Q->>S: peticion con signal S-->>Q: respuesta Q-->>M: dato revalidado end M->>M: transicion al paso siguiente M->>Q: orden de invalidar claves afectadas
El error más común de esta integración es hacer assign del resultado en el contexto para tenerlo a mano. En ese instante pierdes las dos cosas por las que existían ambas capas: la cache deja de ser la única fuente de verdad —hay una copia que ya no se revalida— y la máquina deja de ser un modelo del flujo, porque ahora también es un almacén de datos con su propia caducidad implícita. Si necesitas el valor para una guarda, léelo dentro del actor y devuelve la decisión, o guarda solo el resumen mínimo que el flujo necesita: un identificador, un booleano derivado, un total ya calculado.
Bajo la división del trabajo hay una asimetría temporal que explica por qué ninguna de las dos capas puede absorber a la otra. La máquina vive en el tiempo del usuario: un tiempo lineal, con un presente inequívoco, donde cada evento ocurre después del anterior y el pasado no se revisa. La cache vive en el tiempo del servidor: un tiempo donde el dato que leíste hace treinta segundos puede haber cambiado sin avisarte, donde el presente es siempre una hipótesis y donde la pregunta relevante no es qué pasó antes sino cuánta confianza merece lo que tengo. Son dos regímenes temporales incompatibles, y cada herramienta encarna uno. Por eso cuando metes datos de servidor dentro del contexto de la máquina, estás forzando un valor del segundo régimen a comportarse según el primero: lo congelas en un instante del flujo y le prohíbes envejecer, cuando su naturaleza es envejecer. Y por eso, simétricamente, cuando intentas expresar el orden de un procedimiento con dependencias entre consultas, estás pidiéndole al régimen de la frescura que exprese secuencia, algo para lo que no tiene vocabulario. La frontera entre ambas no es una convención de equipo ni una preferencia de estilo: es el reconocimiento de que el orden y la frescura son dos dimensiones ortogonales del mismo sistema, y que cada una necesita su propia autoridad para no corromper a la otra. Las arquitecturas que envejecen bien son las que mantienen esa frontera visible; las que se pudren son las que la difuminan un assign cada vez.
- Toma un flujo real con máquina y clasifica cada decisión suya en la tabla: orden o frescura, máquina o cache.
- Sustituye la llamada directa a
fetchde un actor por una llamada a la cache con clave ystaleTime, y verifica que el aborto sigue propagándose. - Recorre el contexto de esa máquina y marca cada campo que sea una copia de un dato de servidor; intenta eliminarlo dejando solo su identificador.
- Implementa el actor observador para un recurso que pueda cambiar desde fuera y decide explícitamente en qué estados es legal recibir su evento.
- Convierte una invalidación escrita dentro de un actor en una acción nombrada, y comprueba que la máquina se puede probar sustituyéndola con
provide. - Documenta en una frase, para tu equipo, qué información puede cruzar la frontera en cada dirección y cuál no.