wandres.dev
XSTATE V5: SETUP Y TIPOS · type-safety total

Implementaciones nombradas: actions y guards con nombre propio

Dar nombre a una action o a un guard no es una convención de estilo: es la operación que convierte un efecto en un símbolo del vocabulario de la máquina, y con ello lo hace autocompletable, verificable, dibujable en el diagrama y sustituible en un test. Esta lección construye el catálogo completo: la firma de dos argumentos que reciben las implementaciones, los parámetros que permiten a una action dejar de depender del evento que la disparó —resolviendo la asimetría del estrechamiento sin un solo casting—, los combinadores de guards and, or, not y stateIn, la orquestación condicional con enqueueActions que sustituye al pure y al choose de la v4, y el intercambio de implementaciones con provide. El hilo conductor es una sola tesis: la máquina nombra, setup implementa, y nada anónimo sobrevive a una revisión seria.

⏱ 19 min

Una acción escrita directamente dentro de una transición funciona perfectamente y es, en casi todos los sentidos que importan, invisible. No aparece con etiqueta en el diagrama, no se puede sustituir por un doble en un test, no se puede reutilizar en la transición de al lado y no figura en ninguna lista que alguien pueda leer para saber qué efectos provoca esta máquina. Darle un nombre y declararla en setup cambia su categoría ontológica: deja de ser código incrustado y pasa a ser un símbolo del vocabulario, con todo lo que un símbolo permite —referenciarlo, contarlo, dibujarlo, reemplazarlo—. Esta lección desarrolla ese acto de nombrar hasta sus últimas consecuencias, incluida la más elegante: cuando una implementación recibe por parámetro exactamente lo que necesita, deja de importarle qué evento la disparó, y la incomodidad que dejamos abierta en la lección anterior se disuelve sin engañar al compilador ni una sola vez.

🎯 Al terminar esta lección sabrás
  • Escribir actions y guards nombrados en setup y referenciarlos por cadena desde la máquina.
  • Usar el segundo argumento de parámetros para desacoplar una implementación del evento que la dispara.
  • Componer condiciones con and, or, not y stateIn, y orquestar secuencias con enqueueActions.
  • Intercambiar implementaciones con provide y extraer la lógica pura a funciones reutilizables.

Qué compra un nombre

Las implementaciones de setup reciben siempre dos argumentos. El primero es un objeto con context, event y self; el segundo, los parámetros que la transición decida pasarle. Los guards tienen la misma firma y devuelven un booleano; las actions no devuelven nada y son el único sitio donde se permiten efectos.

🔤

Vocabulario cerrado

El nombre entra en una unión de cadenas literales. Escribirlo mal deja de ser un fallo silencioso en tiempo de ejecución y pasa a ser un error de compilación en la línea exacta.

🗺️

Diagrama legible

Stately y el inspector muestran el nombre en la arista. Una función anónima aparece como una caja sin texto; un nombre bien elegido documenta el efecto sin abrir el fichero.

🧪

Sustituible

provide solo puede reemplazar lo que tiene nombre. Cada implementación anónima es una dependencia soldada que ningún test podrá desactivar.

♻️

Reutilizable

El mismo nombre sirve en varias transiciones, y con parámetros distintos en cada una, sin duplicar el cuerpo ni una sola vez.

Parámetros: la implementación deja de mirar el evento

Recuerda la asimetría de la lección anterior: una implementación nombrada recibe la unión completa de eventos, porque setup se evalúa antes de que la máquina exista. La salida no es comprobar event.type dentro de cada action, y mucho menos silenciar el tipo: es que la action no necesite el evento. La transición, que sí conoce su evento, calcula los parámetros y se los entrega ya extraídos.

import { setup, assign, and, not } from 'xstate'

type Articulo = { id: string; precio: number }

const carrito = setup({
  types: {
    context: {} as { articulos: Articulo[]; limite: number },
    events: {} as
      | { type: 'ANADIR'; articulo: Articulo }
      | { type: 'QUITAR'; id: string }
      | { type: 'PAGAR' },
  },
  actions: {
    // no menciona el evento: recibe el articulo como parametro
    anadirArticulo: assign({
      articulos: ({ context }, params: { articulo: Articulo }) => [
        ...context.articulos,
        params.articulo,
      ],
    }),
    registrar: (_, params: { etiqueta: string }) => console.log(params.etiqueta),
  },
  guards: {
    cabeUnoMas: ({ context }) => context.articulos.length < context.limite,
    superaMinimo: ({ context }, params: { minimo: number }) =>
      context.articulos.reduce((s, a) => s + a.precio, 0) >= params.minimo,
    carritoVacio: ({ context }) => context.articulos.length === 0,
  },
}).createMachine({
  context: { articulos: [], limite: 10 },
  initial: 'comprando',
  states: {
    comprando: {
      on: {
        ANADIR: {
          guard: 'cabeUnoMas',
          // aqui event SI esta estrechado: params se calcula en el sitio que lo sabe
          actions: [
            { type: 'anadirArticulo', params: ({ event }) => ({ articulo: event.articulo }) },
            { type: 'registrar', params: { etiqueta: 'articulo anadido' } },
          ],
        },
        PAGAR: {
          guard: and([not('carritoVacio'), { type: 'superaMinimo', params: { minimo: 20 } }]),
          target: 'pagando',
        },
      },
    },
    pagando: {},
  },
})

El reparto de responsabilidades es exacto y merece detenerse en él. El estrechamiento ocurre donde es posible —en la transición, que conoce su evento— y el desacoplamiento ocurre donde es útil —en la implementación, que así sirve para cualquier origen—. Ni un casting, ni una comprobación redundante, y de propina anadirArticulo es ahora reutilizable desde una transición disparada por un evento completamente distinto.

💡
Los params pueden ser un valor o una función

Cuando el parámetro es constante lo pasas tal cual, como en params: { etiqueta: 'articulo anadido' }. Cuando depende del evento o del context, pasas una función que los recibe y devuelve el objeto de parámetros. Esa función se escribe en línea, en la transición, y por eso ve el evento ya estrechado. Es la costura entre las dos mitades del sistema: el sitio donde se conoce el contexto de la llamada produce los datos, y el sitio donde vive la lógica los consume sin preguntar de dónde vienen. Un guard parametrizado como superaMinimo sigue el mismo esquema y te evita escribir tres guards casi idénticos con umbrales distintos.

flowchart LR
T[transicion: conoce su evento] -->|calcula params| A[action nombrada]
T -->|referencia por nombre| A
S[setup: implementa la logica] --> A
A --> E[efecto o assign]
P[provide en un test] -.sustituye.-> A
style T fill:#89b4fa,color:#11111b
style S fill:#cba6f7,color:#11111b
style P fill:#a6e3a1,color:#11111b

Componer, orquestar y sustituir

Los guards nombrados se combinan sin escribir booleanos a mano. and y or toman listas, not niega, y stateIn pregunta por el estado actual de otra región, que es la herramienta canónica para coordinar regiones paralelas sin duplicar información en el context. Todos aceptan tanto un nombre suelto como un objeto con type y params, de modo que la composición no obliga a renunciar a la parametrización.

Para las secuencias condicionales de acciones, la v5 retira pure y choose y los reemplaza por una sola primitiva más expresiva. enqueueActions recibe una función que puede consultar guards con check e ir encolando acciones con enqueue, incluidas variantes como enqueue.assign, enqueue.raise, enqueue.sendTo y enqueue.spawnChild.

import { enqueueActions } from 'xstate'

const conOrquestacion = setup({
  types: { context: {} as { articulos: Articulo[]; limite: number }, events: {} as { type: 'PAGAR' } },
  guards: { casiLleno: ({ context }) => context.articulos.length >= context.limite - 1 },
  actions: {
    avisar: (_, params: { texto: string }) => console.log(params.texto),
    prepararPago: enqueueActions(({ check, enqueue }) => {
      if (check('casiLleno')) enqueue({ type: 'avisar', params: { texto: 'carrito casi lleno' } })
      enqueue.assign({ articulos: [] })
      enqueue.raise({ type: 'PAGAR' })
    }),
  },
})

Y como todo lo anterior tiene nombre, todo lo anterior es sustituible. provide devuelve una máquina nueva, idéntica en estructura, con las implementaciones que indiques reemplazadas: el grafo no cambia, solo cambia el relleno de los símbolos.

const carritoBajoTest = carrito.provide({
  actions: { registrar: () => {} },            // efecto silenciado
  guards: { superaMinimo: () => true },        // umbral neutralizado
})
⚠️
Reutilizar entre máquinas exige extraer la lógica, no el nombre

Los nombres declarados en un setup pertenecen a esa máquina y no se heredan. Si quieres compartir comportamiento entre máquinas, lo que se extrae es la lógica pura a una función normal —function calcularTotal(articulos: Articulo[]): number— y cada setup registra una action o un guard fino que la invoca. Resistir la tentación de fabricar una capa de implementaciones compartidas es sano por una razón de diseño: cada máquina tiene su propio context y su propia unión de eventos, y una implementación genérica sobre todas ellas acaba pidiendo tipos amplios que devuelven al punto de partida. Comparte funciones puras; declara adaptadores por máquina.

Nombrar es el acto que convierte código en modelo

Detrás de toda esta mecánica hay una tesis que trasciende a XState: un sistema solo puede razonar sobre aquello que tiene nombre. Una función anónima incrustada en una arista es perfectamente ejecutable y absolutamente opaca; existe para el intérprete y no existe para nadie más. En el instante en que la declaras en setup y la referencias por una cadena, esa misma función se vuelve enumerable —se puede listar—, direccionable —se puede señalar—, sustituible —se puede reemplazar— y representable —se puede dibujar—. No has cambiado ni una línea de su cuerpo y sin embargo has cambiado lo que el resto del mundo puede hacer con ella. Eso es exactamente lo que separa un programa de un modelo: el programa ejecuta, el modelo además se deja inspeccionar, y la frontera entre ambos la traza la existencia de nombres. Los parámetros son el corolario fino de la misma idea. Si una implementación se define por lo que hace y no por quién la llama, su nombre denota una operación del dominio y no un accidente del grafo, y entonces es genuinamente reutilizable, componible y verificable en aislamiento; si en cambio inspecciona el evento para averiguar en qué transición está, su nombre miente, porque en realidad son varias operaciones distintas compartiendo una etiqueta. De ahí la disciplina que resume el nivel entero: la máquina nombra, setup implementa, la transición aporta los datos y nada anónimo debería sobrevivir a una revisión seria. No porque quede más limpio, sino porque cada anonimato es una porción de tu comportamiento que ninguna herramienta —ni el compilador, ni el visualizador, ni el test— podrá volver a mirar.

⚔️ Convierte lo anónimo en vocabulario
  1. Recorre una máquina real y haz inventario de cada implementación anónima. Para cada una, decide un nombre que describa la operación del dominio, no la transición donde vive.
  2. Toma una action nombrada que inspeccione event.type para decidir qué hacer y refactorízala a parámetros. Comprueba que su cuerpo ya no menciona el evento.
  3. Escribe un guard parametrizado que sustituya a dos o tres guards casi idénticos con umbrales distintos, y úsalo con params diferentes en cada transición.
  4. Compón una condición compleja con and, or y not en lugar de un guard monolítico, y valora cuál de las dos versiones se lee mejor en el diagrama.
  5. Sustituye un pure o un choose heredado de la v4 por enqueueActions con check, y anota qué gana en legibilidad la versión nueva.
  6. Escribe un test que use provide para silenciar los efectos y forzar un guard, y comprueba que la lógica recorrida es idéntica a la de producción.