Estados paralelos: `type: parallel` y las regiones que conviven
Un nodo paralelo no elige un hijo: los activa todos. Cada hijo pasa a ser una región con su propia submáquina, su propio estado activo y su propia vida, y el estado del sistema deja de ser un camino para convertirse en un abanico de caminos simultáneos. Esta lección construye el ejemplo canónico —un editor de texto cuyas dimensiones de edición, guardado y conexión no se estorban entre sí—, explica la difusión de un evento a todas las regiones y la resolución independiente de cada una, detalla el contrato de `onDone` cuando hay varias regiones que deben terminar, y fija el criterio para distinguir una ortogonalidad genuina de dos máquinas que en realidad querían ser una sola.
La jerarquía resolvió el problema de las transiciones repetidas, pero no toca el otro modo de explosión: el de las dimensiones independientes. Un documento abierto puede estar a la vez en mitad de una selección, con cambios sin guardar y con la red caída, y esas tres circunstancias no se determinan entre sí. Modelarlas como estados exclusivos obliga a inventar un nodo por cada combinación, y el catálogo crece como un producto cartesiano. El nodo paralelo corta ese producto de raíz: declara varias regiones que están todas activas a la vez, cada una gobernando una sola dimensión, y deja que el sistema ocupe una posición por región en lugar de una posición única. El estado deja de ser un camino y pasa a ser un abanico.
- Declarar un nodo
type: parallely entender por qué prohíbeinitial. - Leer el valor del snapshot como un conjunto de hojas activas, una por región.
- Explicar la difusión de un evento y la resolución independiente en cada región.
- Distinguir ortogonalidad genuina de acoplamiento disfrazado de paralelismo.
El editor: tres dimensiones que no se estorban
Un editor de texto es el ejemplo canónico porque sus dimensiones son visiblemente independientes: lo que hace el cursor no determina si hay cambios pendientes, y ninguna de las dos cosas decide si hay red. Cada dimensión se convierte en una región, y las regiones se declaran como hijos de un nodo marcado con type: parallel.
import { setup } from 'xstate'
const editor = setup({
types: {} as {
events:
| { type: 'ESCRIBIR' }
| { type: 'SELECCIONAR' }
| { type: 'SOLTAR' }
| { type: 'GUARDAR' }
| { type: 'GUARDADO' }
| { type: 'RED_CAIDA' }
| { type: 'RED_OK' }
},
}).createMachine({
id: 'editor',
type: 'parallel', // sin initial: se activan TODAS las regiones
states: {
edicion: {
initial: 'inactivo',
states: {
inactivo: { on: { ESCRIBIR: 'escribiendo', SELECCIONAR: 'seleccionando' } },
escribiendo: { on: { SELECCIONAR: 'seleccionando' } },
seleccionando: { on: { SOLTAR: 'inactivo' } },
},
},
guardado: {
initial: 'limpio',
states: {
limpio: { on: { ESCRIBIR: 'sucio' } },
sucio: { on: { GUARDAR: 'guardando' } },
guardando: { on: { GUARDADO: 'limpio' } },
},
},
conexion: {
initial: 'online',
states: {
online: { on: { RED_CAIDA: 'offline' } },
offline: { on: { RED_OK: 'online' } },
},
},
},
})
El valor del snapshot refleja la simultaneidad: ya no es una rama sino un objeto con una clave por región, y todas las claves están presentes siempre, porque ninguna región puede estar apagada mientras el nodo paralelo esté activo.
actor.getSnapshot().value
// { edicion: 'inactivo', guardado: 'limpio', conexion: 'online' }
actor.send({ type: 'ESCRIBIR' })
actor.getSnapshot().value
// { edicion: 'escribiendo', guardado: 'sucio', conexion: 'online' }
La clave initial existe para responder a la pregunta de a cuál de mis hijos entro, y esa pregunta no tiene sentido en un nodo paralelo porque la respuesta es a todos. Declararla sería contradecir la semántica del nodo, así que XState la rechaza. Lo que sí necesita cada región por separado es su propio initial, porque cada región es un compuesto ordinario con hijos exclusivos entre sí. Dicho de otro modo: la exclusividad no desaparece con el paralelismo, se traslada un nivel hacia dentro.
Difusión: un evento, varias respuestas
La segunda propiedad esencial es cómo se resuelven los eventos. Un evento no se entrega a una región concreta, se difunde a todas, y cada una decide por su cuenta si lo maneja. Mira ESCRIBIR en el ejemplo anterior: la región edicion lo usa para pasar a escribiendo y la región guardado lo usa para pasar a sucio. Ninguna de las dos sabe de la existencia de la otra, y sin embargo un solo gesto del usuario actualiza las dos dimensiones de forma coherente.
flowchart TD A[evento ESCRIBIR] --> B[region edicion] A --> C[region guardado] A --> D[region conexion] B --> E[inactivo pasa a escribiendo] C --> F[limpio pasa a sucio] D --> G[no lo maneja: sin cambio] style E fill:#a6e3a1,color:#11111b style F fill:#89b4fa,color:#11111b style G fill:#585b70,color:#cdd6f4
Esto es acoplamiento cero con coordinación total, y es exactamente lo que se pierde al modelar las mismas dimensiones como estados combinados. En una máquina plana habría que declarar la transición de ESCRIBIR para cada combinación de las tres dimensiones, y añadir una cuarta dimensión multiplicaría de nuevo el trabajo. Con regiones, añadir una dimensión es añadir una región, y el coste es aditivo.
Difusión a todas
Cada evento se ofrece a todas las regiones activas. Las que lo declaran transitan; las que no, se quedan donde están sin error.
Resolución independiente
Dentro de cada región el evento burbujea por su propia jerarquía. Una región no puede apuntar a un estado de otra región hermana.
Coste aditivo
Con k dimensiones binarias, el paralelismo pide dos nodos por dimensión en vez de dos elevado a k combinaciones.
Final conjunto
El nodo paralelo solo emite onDone cuando todas sus regiones han alcanzado un estado final. Es una unión de esperas, no una carrera.
Una consecuencia práctica que confunde a quien llega desde la concurrencia real: aquí no hay hilos ni indeterminismo. La resolución de un evento en todas las regiones ocurre dentro de un mismo paso, de forma determinista y en el orden de declaración de las regiones. Si dos regiones ejecutan acciones que escriben en el mismo campo del context, el resultado no es una condición de carrera sino una secuencia predecible; lo cual, dicho sea de paso, es una señal fuerte de que esas dos regiones no eran tan ortogonales como parecía.
El síntoma clásico de un paralelismo mal puesto es un assign en una región que otra región lee para decidir su propia transición. Eso es comunicación encubierta, y contradice la premisa de la ortogonalidad: si una región necesita saber el estado de otra para comportarse bien, esas dimensiones no son independientes y no debían haberse separado. Las alternativas honestas son anidar una dentro de otra si hay dependencia jerárquica, fundirlas en una sola región si la dependencia es mutua, o dejarlas separadas y hacer explícita la comunicación con un evento en lugar de con una lectura silenciosa de estado compartido.
onDone y la espera conjunta
El contrato de terminación merece atención propia porque es el punto donde el paralelismo deja de ser cómodo y empieza a ser expresivo. Un nodo paralelo alcanza su estado final cuando todas sus regiones están en un final, no cuando la primera lo consigue. Esto convierte al nodo paralelo en la forma nativa de expresar una espera conjunta sin escribir un contador a mano.
verificacion: {
type: 'parallel',
states: {
correo: {
initial: 'pendiente',
states: {
pendiente: { on: { CORREO_OK: 'listo' } },
listo: { type: 'final' },
},
},
telefono: {
initial: 'pendiente',
states: {
pendiente: { on: { SMS_OK: 'listo' } },
listo: { type: 'final' },
},
},
},
onDone: 'verificado', // solo cuando AMBAS regiones han terminado
}
Sin paralelismo, esta espera se escribiría con dos banderas en el context y un guard que comprueba ambas en cada transición: un contador manual, con su lote habitual de olvidos. Con regiones, la espera conjunta es estructural y el intérprete la garantiza. Esta es la forma más limpia de justificar un nodo paralelo cuando la ortogonalidad de las dimensiones no es evidente por sí sola.
La forma profunda de entender un nodo paralelo es reconocerlo como un tipo producto que el intérprete mantiene en forma factorizada. Cuando declaras tres regiones de tres, tres y dos estados, el espacio real de configuraciones del sistema sigue siendo el producto de esos tamaños, dieciocho combinaciones; lo que cambia es que jamás las escribes, jamás las nombras y jamás razonas sobre ellas de una en una. La ortogonalidad no elimina la complejidad combinatoria del dominio, que es irreducible, sino la complejidad combinatoria de tu descripción, que sí lo era. Ese desacople entre el tamaño del espacio y el tamaño del texto que lo describe es la misma victoria que consigue cualquier buena abstracción: expresar muchos casos con pocas reglas. Y de ahí se sigue el criterio para decidir si una dimensión merece región propia, que es la pregunta que de verdad importa: solo si su comportamiento es genuinamente independiente del de las demás, es decir, si la tabla de transiciones de esa región se puede escribir entera sin mencionar ni una sola vez el estado de otra región. Cuando esa condición se cumple, el producto factorizado es fiel al dominio y todo funciona; cuando no, el modelo miente y el error se manifiesta tarde, en forma de guardas que espían regiones vecinas, campos de contexto que hacen de canal encubierto y comportamientos que solo aparecen en ciertas combinaciones concretas. El paralelismo no es, entonces, la herramienta para hacer varias cosas a la vez, sino la herramienta para afirmar que varias cosas son independientes. Si esa afirmación es falsa, ninguna cantidad de sintaxis la vuelve verdadera, y lo que has construido no es un statechart ortogonal sino dos máquinas acopladas fingiendo no conocerse.
- Construye la máquina
editorcon las tres regiones y comprueba que el valor inicial contiene las tres claves a la vez, sin haber enviado ningún evento. - Envía
ESCRIBIRy verifica que dos regiones transitan con un único evento mientras la tercera queda intacta. - Añade
initialal nodo paralelo y observa el rechazo de XState; explica con tus palabras por qué esa clave contradice la semántica del nodo. - Cuenta cuántos estados haría falta declarar para modelar las mismas tres dimensiones sin paralelismo, y compáralo con el número de nodos que escribiste realmente.
- Implementa la verificación conjunta de correo y teléfono con
onDoney comprueba que la transición averificadosolo ocurre tras completar ambas regiones, en cualquier orden. - Introduce deliberadamente un
guarden una región que lea un campo delcontextescrito por otra región, observa el acoplamiento resultante y decide si el remedio correcto es anidar, fundir o comunicar con un evento explícito.