wandres.dev
XSTATE: CONTEXT Y LÓGICA · guards y actions

assign: mutar el context sin mutarlo

El context nunca se modifica a mano: no existe context.x igual a y dentro de una maquina de XState. La unica via sancionada para actualizar el estado extendido es la action assign, que en respuesta a un evento calcula un context NUEVO a partir del anterior y del payload del evento, y deja que el interprete lo sustituya de forma inmutable. Esta leccion diseca las dos formas de assign —por propiedad y por funcion—, explica por que la inmutabilidad no es un capricho sino la condicion de la reproducibilidad, y aclara la regla de orden de ejecucion de XState v5 que corrige el gran gotcha de la v4.

⏱ 16 min

En la leccion anterior separamos el context —la memoria cuantitativa— de los estados finitos. Queda la pregunta operativa: como cambia ese context. La respuesta de XState es tajante y a primera vista extrana: nunca lo tocas directamente. No hay un context.cuenta += 1 dentro de una maquina. En su lugar declaras una action especial, assign, que describe COMO calcular el proximo context a partir del actual y del evento que llego. La maquina no ejecuta esa mutacion cuando la escribes; la guarda como dato y la aplica el interprete, produciendo siempre un objeto nuevo. Entender por que la actualizacion es declarativa e inmutable —y no una asignacion imperativa— es entender por que una maquina se puede rebobinar, testear y serializar.

🎯 Al terminar esta lección sabrás
  • Comprender que assign es la unica via sancionada para actualizar el context de una maquina.
  • Dominar las dos formas de assign: el objeto de asignadores por propiedad y la funcion que devuelve el cambio completo.
  • Justificar por que el nuevo context se construye de forma inmutable y que aporta esa disciplina.
  • Aplicar la regla de orden de ejecucion de XState v5, donde cada assign ve el context ya actualizado por los anteriores.

assign: la unica puerta al context

assign es una action, es decir, un efecto declarado que la maquina asocia a una transicion. Su cometido unico es reemplazar el context. Recibe el context actual y el event que disparo la transicion —con todo su payload— y entrega los cambios; el interprete se encarga de fusionarlos en un objeto nuevo.

import { setup, assign } from 'xstate'

const contador = setup({
  types: {
    context: {} as { cuenta: number; historial: number[] },
    events: {} as { type: 'SUMA'; por: number } | { type: 'RESET' },
  },
}).createMachine({
  context: { cuenta: 0, historial: [] },
  on: {
    SUMA: {
      actions: assign(({ context, event }) => ({
        cuenta: context.cuenta + event.por,               // lee el payload del evento
        historial: [...context.historial, context.cuenta], // copia, no muta
      })),
    },
    RESET: { actions: assign({ cuenta: 0, historial: [] }) },
  },
})

Fijate en tres hechos. Primero, el evento SUMA transporta un dato, por, y assign lo lee: el context se actualiza en funcion de lo que ocurrio. Segundo, historial se reconstruye con un spread, jamas con un push sobre el array existente. Tercero, assign no cambia el estado finito: aqui la transicion es interna —se queda en el mismo estado— y solo reescribe la memoria.

Las dos formas: por propiedad y por funcion

assign acepta dos sintaxis equivalentes en potencia pero distintas en ergonomia. La forma por funcion recibe el context y el event y devuelve un objeto con las claves a cambiar; es la mas legible cuando varias propiedades dependen entre si o del evento. La forma por objeto recibe un mapa de propiedad a asignador, donde cada valor puede ser una constante o una funcion ({ context, event }) => valor; brilla cuando cada campo se calcula por separado.

// forma por objeto: un asignador independiente por propiedad
actions: assign({
  cuenta: ({ context, event }) => context.cuenta + event.por,
  historial: ({ context }) => [...context.historial, context.cuenta],
})

En ambas formas devuelves SOLO las claves que cambian; XState conserva el resto del context intacto. No necesitas repetir las propiedades que no tocas: la fusion es superficial y respeta lo que no mencionas.

ℹ️
La fusion es superficial, no profunda

Ojo con la palabra superficial: assign fusiona solo en el primer nivel. Si devuelves usuario con un objeto nuevo, reemplazas el usuario ENTERO, no unicamente el campo que cambiaste; por eso, al anidar, debes copiar el objeto interior tu mismo con spread. La fusion superficial te ahorra repetir las claves de primer nivel que quedan intactas, pero no clona los objetos anidados: de ahi que el ejemplo de structural sharing tenga que reconstruir el camino a mano hasta el dato que cambia.

💡
Cuando cada forma

Usa la forma por objeto cuando los campos se calculan de manera independiente, porque documenta campo a campo de que depende cada uno y es la que mejor infiere tipos. Usa la forma por funcion cuando el nuevo valor de un campo depende del nuevo valor de otro dentro del mismo evento, o cuando quieres derivar varios campos de un calculo comun. En una base de codigo grande conviene fijar una de las dos como estilo por defecto y reservar la otra para los casos que de verdad la piden.

📝
El context inicial tambien se calcula, con input

No solo assign produce context: el context INICIAL puede ser una funcion que recibe el input con el que arrancas el actor —context: ({ input }) => ({ saldo: input.saldoInicial })—. Asi la misma maquina nace con datos distintos segun quien la crea, sin cablear valores en la definicion. El input gobierna el arranque, assign gobierna las actualizaciones, y entre los dos el context nunca necesita mutarse a mano ni una sola vez en toda la vida del actor.

Inmutabilidad: por que no mutas a mano

La regla de oro es que el asignador debe DEVOLVER un context nuevo, nunca modificar el que recibe. Escribir context.cuenta += 1 dentro de un asignador es un error silencioso: rompe la comparacion por referencia de la que dependen la deteccion de cambios, el memo de los selectores y las herramientas de depuracion.

⚠️
assign es puro: nada de mutaciones ni de asincronia

Dos prohibiciones nacen de la misma raiz. La primera: no mutes el context recibido —ni context.lista.push(x), ni context.obj.k = v—; construye estructuras nuevas con spread o con un helper como Immer. La segunda: no metas efectos secundarios ni codigo asincrono dentro de assign —nada de fetch, ni setTimeout, ni escribir en disco—. assign describe una transformacion pura de datos; lo asincrono se modela con actores invocados —invoke, leccion 6 del nivel— y lo impuro con actions de efecto —leccion 4—. Un asignador que hace una peticion es un sintoma de que estas mezclando la memoria con el mundo exterior.

La inmutabilidad tiene un premio concreto: como cada evento produce un context nuevo y distinto por referencia, puedes guardar la secuencia de contexts y rebobinar el sistema a cualquier punto, exactamente como un depurador con viaje en el tiempo. Un context que se mutara en el sitio destruiria ese historial, porque todas las referencias apuntarian al ultimo valor.

Cuando el context anida objetos o arrays, la copia debe llegar hasta el nivel que cambias: reescribes el camino y compartes el resto, la tecnica de structural sharing que ya viste en el nivel de inmutabilidad.

// actualizar un campo anidado sin mutar: copia el camino, comparte el resto
assign(({ context, event }) => ({
  usuario: { ...context.usuario, nombre: event.nombre },   // objeto nuevo arriba
  etiquetas: context.etiquetas,                             // referencia intacta abajo
}))
⏮️

Viaje en el tiempo

Cada evento deja un context nuevo e intacto. Guardar la secuencia permite rebobinar el sistema a cualquier punto sin perder ninguno.

🧪

Testeable

El proximo context es funcion pura del anterior y del evento. Puedes afirmar sobre el resultado sin montar nada real.

🧬

Structural sharing

Copiar solo el camino que cambia y compartir el resto abarata la inmutabilidad, aun con estructuras grandes.

💾

Serializable

El estado total son datos planos: se vuelca a JSON, se persiste y se hidrata sin logica escondida en mutaciones.

El orden de ejecucion: la correccion de la v5

Aqui la version 5 arregla el error mas famoso de la version 4. En XState v4, todas las assign de una transicion se aplicaban ANTES que cualquier otra action, sin importar el orden en que las hubieras escrito; esto rompia la intuicion y generaba bugs sutiles. En XState v5 las actions se ejecutan en el orden en que las declaras, y cada assign ve el context ya actualizado por las anteriores.

actions: [
  assign({ cuenta: ({ context }) => context.cuenta + 1 }),   // cuenta pasa a 1
  assign({ doble: ({ context }) => context.cuenta * 2 }),    // ve cuenta 1 -> doble 2
  ({ context }) => console.log(context.cuenta, context.doble), // efecto: imprime 1 2
]

En v4 esa misma secuencia imprimia el context viejo porque los assign se adelantaban; en v5 el flujo es lineal y predecible: cada paso observa el resultado del anterior. Esta linealidad es lo que permite razonar sobre una transicion como una tuberia de transformaciones.

sequenceDiagram
participant EV as evento SUMA por 5
participant M as maquina
participant CTX as context
EV->>M: llega con payload por 5
M->>CTX: assign lee cuenta 0 y por 5
CTX-->>M: devuelve context nuevo cuenta 5
M->>CTX: siguiente action ve cuenta 5
M-->>EV: snapshot final con context 5

Esta garantia de orden convierte una transicion en algo que puedes leer como un guion: primero esto, luego aquello, y cada linea ve el resultado de la anterior. Sin ella, razonar sobre varias assign juntas seria adivinar; con ella, es leer de arriba abajo. Esa legibilidad convierte a la maquina en documentacion viva de su propia logica: el orden que lees es exactamente el orden que corre. Pocas partes de un stack de estado te regalan esa correspondencia literal entre lo que se lee y lo que se ejecuta.

assign no muta el estado: describe como calcularlo

El salto conceptual es dejar de leer assign como una orden y empezar a leerlo como una descripcion. Cuando escribes assign, no estas cambiando nada en ese instante: estas anadiendo a la maquina un dato que dice como se obtendra el proximo context cuando llegue tal evento. Es el interprete, en tiempo de ejecucion, quien aplica esa descripcion y produce el objeto nuevo. Esta indireccion —separar la DESCRIPCION del calculo de su EJECUCION— es exactamente lo que convierte a la maquina en una funcion pura de estado y evento: dado un context, un value y un event, el siguiente context queda determinado sin ambiguedad y sin tocar el mundo. De ahi salen todas las propiedades que hacen valiosa a una maquina de estados: es reproducible, porque la misma secuencia de eventos regenera la misma secuencia de contexts; es testeable, porque puedes afirmar sobre el context resultante sin montar nada real; es serializable, porque el estado total es datos planos; y es depurable con viaje en el tiempo, porque cada context es un objeto nuevo que sobrevive al siguiente. Mutar el context a mano tirania las cuatro por la borda. assign es la disciplina que lo impide: la unica puerta a la memoria, y una puerta que solo deja pasar transformaciones puras.

⚔️ Domina la memoria
  1. Escribe una maquina de carrito con context que guarde una lista de items y un total, y un evento ANADE que lleve el item en su payload.
  2. Implementa el assign que anade al array sin mutarlo y recalcula el total, primero en forma por objeto y luego en forma por funcion; comprueba que se comportan igual.
  3. Provoca a proposito el bug de mutacion: haz context.items.push(item) y observa que la referencia del array no cambia y que la UI o el selector memoizado no se entera.
  4. Encadena dos assign donde el segundo dependa del resultado del primero y verifica, con un console.log al final, que en v5 el orden es el que escribiste.
  5. Explica en dos frases por que meter un fetch dentro de un assign viola su contrato y donde deberia vivir esa peticion.