La configuración: el conjunto de estados activos
En un statechart la máquina nunca está en un estado: está en un conjunto de estados simultáneamente activos que forma un subárbol del documento. Ese conjunto es la configuración, y SCXML lo somete a tres reglas de consistencia que ningún intérprete puede violar: cierre bajo ancestros, exactamente un hijo activo por estado compuesto y todos los hijos activos por estado paralelo. Esta lección define la configuración con precisión, deriva sus reglas, muestra cómo el algoritmo calcula los conjuntos de entrada y salida a partir del dominio de cada transición, y explica por qué el valor que lees en XState es solo una codificación compacta del conjunto de estados atómicos.
La pregunta más engañosa que se le puede hacer a un statechart es en qué estado está. La formulación presupone un singular que dejó de ser cierto en el nivel 2, cuando la jerarquía convirtió cada estado en un contenedor y la ortogonalidad permitió que varias regiones vivieran a la vez. Lo que una máquina tiene en cada instante no es un estado sino una configuración: un conjunto de estados activos que no es cualquier conjunto, sino un subárbol del documento sometido a reglas estructurales estrictas. Esas reglas son lo que impide que existan las combinaciones absurdas —estar en el hijo sin estar en el padre, estar en dos hermanos exclusivos a la vez— y son la razón última por la que los statecharts eliminan estados imposibles por construcción. No los prohíben con validaciones: los vuelven inexpresables.
- Definir la configuración como subárbol de estados activos y no como valor único.
- Enunciar y justificar las tres reglas de consistencia que la mantienen legal.
- Calcular los conjuntos de entrada y salida a partir del dominio de una transición.
- Traducir la configuración formal al valor observable de XState y de vuelta.
Qué es exactamente una configuración
Una configuración es el conjunto de todos los estados activos en un instante dado. Incluye los estados atómicos —los que no tienen hijos— y también todos sus ancestros hasta la raíz. Si la máquina está ejecutando Detalle, dentro de Panel, dentro de Sesion, la configuración no es un elemento sino cuatro: la raíz, Sesion, Panel y Detalle.
flowchart TD R[raiz scxml] --> S[Sesion] R --> B[Bloqueada] S --> P[Panel] S --> A[Ajustes] P --> L[Lista] P --> D[Detalle] style R fill:#a6e3a1,color:#11111b style S fill:#a6e3a1,color:#11111b style P fill:#a6e3a1,color:#11111b style D fill:#a6e3a1,color:#11111b
Los nodos coloreados forman la configuración; los demás están inactivos. La imagen deja ver la propiedad estructural clave: una configuración legal es siempre un subárbol conexo que arranca en la raíz y desciende hasta las hojas. No hay islas, no hay saltos, no hay un descendiente activo cuyo padre no lo esté.
Que los ancestros formen parte de la configuración y no sean mero contexto tiene consecuencias operativas inmediatas. Sus acciones de entrada ya se ejecutaron y sus acciones de salida están pendientes; sus invocaciones están vivas y consumiendo recursos; y, sobre todo, sus transiciones están habilitadas y compiten por los eventos que lleguen. Cuando en la lección anterior el algoritmo ascendía desde cada estado atómico buscando candidatas, ascendía precisamente por esta cadena. Un ancestro activo no es un contenedor pasivo: es un participante de pleno derecho en cada evento que la máquina recibe.
De ahí se sigue un teorema pequeño pero muy útil: la configuración está completamente determinada por su conjunto de estados atómicos. Como todos los ancestros de un estado activo están activos, basta con enumerar las hojas para reconstruir el resto por clausura. Esa redundancia es la que permite que un intérprete almacene poco y que una interfaz muestre poco, y es la base de la codificación compacta que verás en la última sección.
El vocabulario importa más de lo que parece. Hablar del estado en singular invita a modelar la máquina como una variable, y esa metáfora se rompe en cuanto aparece una región paralela: no hay un valor que capture estar en Cargando y en EditorAbierto a la vez sin inventar el producto cartesiano que los statecharts existen precisamente para evitar. Hablar de configuración mantiene la forma correcta en la cabeza: un conjunto con estructura de árbol, no un escalar.
Las tres reglas de consistencia
Un conjunto arbitrario de estados no es una configuración. Para serlo debe satisfacer tres condiciones que el algoritmo mantiene como invariante en todo momento entre microsteps.
Cierre bajo ancestros
Si un estado está en la configuración, todos sus ancestros lo están, incluida la raíz. Es lo que garantiza que el entry de un padre siempre se haya ejecutado antes de que su hijo esté activo.
Exactamente uno por compuesto
Si un estado compuesto está activo, uno y solo uno de sus hijos está activo. Ni cero —esa es la razón de que el atributo initial sea obligatorio— ni dos.
Todos por paralelo
Si un estado parallel está activo, todos sus hijos están activos, y recursivamente lo están las configuraciones internas de cada región.
Al menos una hoja
Toda configuración contiene al menos un estado atómico. Un conjunto formado solo por contenedores no describe ninguna situación ejecutable.
La segunda regla explica de paso una obligación sintáctica que a mucha gente le parece burocrática: por qué todo estado compuesto necesita declarar su hijo inicial. No es una comodidad para el autor sino una necesidad estructural. Si un compuesto pudiera activarse sin que ninguno de sus hijos lo hiciera, la configuración resultante violaría la regla y la máquina quedaría en un limbo sin estados atómicos en esa rama, incapaz de responder a nada. El atributo initial no describe una preferencia: completa la definición del estado compuesto y sin él la máquina no está bien formada.
Las tres primeras reglas no son restricciones arbitrarias sino la definición operativa de lo que significan la jerarquía y la ortogonalidad. La segunda es la exclusión mutua: un estado compuesto es una elección entre alternativas, y una elección tiene exactamente un ganador. La tercera es la concurrencia: un estado paralelo es una conjunción de dimensiones independientes, y una conjunción las tiene todas activas. La primera es la que cose las dos: el contexto en el que una elección o una conjunción tiene sentido debe estar presente para que la elección o la conjunción existan.
De estas reglas se deriva también el tamaño del espacio de configuraciones, y el resultado explica la potencia expresiva de la notación. Un estado compuesto con n hijos aporta n configuraciones posibles; un estado paralelo con regiones de tamaños a y b aporta a por b. Los statecharts no evitan la explosión combinatoria: la representan de forma factorizada, con un documento de tamaño proporcional a la suma mientras el espacio que describe crece con el producto. Esa es, en una frase, la aportación de Harel de 1987.
Merece la pena hacer la cuenta con números. Un editor con cuatro regiones ortogonales de tres, cuatro, dos y cinco estados describe ciento veinte configuraciones legales, y el documento que las declara tiene catorce estados. Una máquina plana equivalente necesitaría los ciento veinte estados escritos uno a uno más todas sus transiciones cruzadas, que es la definición del problema que hizo colapsar a las máquinas de estado en los años ochenta. La compresión no es un truco de presentación: el árbol declara las dimensiones independientes por separado y deja que el producto lo calcule la semántica, exactamente como un tipo producto en un lenguaje de tipos declara campos y no combinaciones.
Un estado history no recuerda un estado sino un fragmento de configuración. La variante superficial guarda qué hijo inmediato de su padre estaba activo; la profunda guarda el conjunto completo de estados atómicos descendientes. Por eso restaurar una historia profunda dentro de una región paralela devuelve todas las ramas a la vez y no solo una: lo que se guardó fue un subárbol entero, y reinstalarlo debe producir de nuevo una configuración legal.
Dominio, conjunto de salida, conjunto de entrada
Cada transición modifica la configuración quitando estados y poniendo otros, y el algoritmo calcula ambos conjuntos con un procedimiento único que empieza por determinar el dominio de la transición. El dominio es el subárbol dentro del cual la transición tiene efecto, y se obtiene así: si la transición no tiene destino, el dominio es su propio origen y no se sale de nada; si es de tipo interno, su origen es compuesto y todos los destinos son descendientes propios del origen, el dominio es el origen; en cualquier otro caso, el dominio es el ancestro compuesto común más bajo entre el origen y los destinos.
// El dominio decide todo lo demas: quien sale, quien entra, que entry se repite.
function getTransitionDomain(t: Transicion): EstadoCompuesto {
const destinos = getEffectiveTargetStates(t)
if (destinos.length === 0) return t.origen
if (t.tipo === 'internal' && esCompuesto(t.origen) &&
destinos.every((d) => esDescendienteDe(d, t.origen))) {
return t.origen
}
return findLCCA([t.origen, ...destinos]) // ancestro compuesto comun mas bajo
}
Con el dominio en la mano, los dos conjuntos salen casi solos. El conjunto de salida son todos los estados activos que son descendientes propios del dominio: se abandonan del más profundo al más superficial, guardando antes los valores de historia y cancelando las invocaciones vivas. El conjunto de entrada son los destinos, más los ancestros que faltan hasta el dominio, más los descendientes que hay que ocupar para volver legal la configuración: el hijo por defecto de cada compuesto, todos los hijos de cada paralelo, y lo que devuelva un estado history si el destino es uno.
Ese último paso —descender hasta las hojas— es el que garantiza la tercera regla de consistencia y produce un efecto que conviene tener presente: apuntar una transición a un estado compuesto nunca deja la máquina ahí. El destino declarado es solo el punto de entrada, y desde él el algoritmo desciende obligatoriamente por los estados iniciales o por la historia hasta alcanzar estados atómicos en todas las ramas abiertas. Por eso una transición a un parallel activa las cuatro regiones aunque el diagrama muestre una sola flecha, y por eso una transición a un compuesto ejecuta también el entry del hijo por defecto sin que nadie lo haya pedido explícitamente.
| Elemento del cálculo | Qué determina | Consecuencia visible |
|---|---|---|
| Dominio de la transición | Hasta dónde llega el efecto | Qué exit y qué entry se ejecutan |
| Ancestro compuesto común más bajo | Dominio en el caso general | Una autotransición externa reejecuta el entry del origen |
Tipo internal |
Evita salir del origen | El entry del padre no se repite en transiciones internas |
| Estados iniciales por defecto | Descenso hasta las hojas | Ocupar un compuesto activa siempre una hoja |
| Estados de historia | Descenso restaurado | Se recupera la configuración previa en lugar de la inicial |
Una transición de un estado a sí mismo tiene como ancestro común más bajo a su padre, de modo que el dominio es el padre y el estado se abandona y se vuelve a ocupar: se ejecutan su exit y su entry, se cancelan y rearrancan sus invocaciones, se pierde el trabajo en curso. Si lo que querías era ejecutar una acción sin reiniciar nada, necesitas una transición sin destino o marcada como interna. Es una de las fuentes de peticiones duplicadas más difíciles de diagnosticar, porque el diagrama se ve idéntico en los dos casos.
De la configuración formal al valor que lees
Y de la misma tabla se lee un consejo de modelado: la elección entre internal y external no es un detalle de configuración sino una decisión sobre el alcance del reinicio. Todo lo que cuelgue del dominio se destruye y se reconstruye, incluidos los temporizadores, las invocaciones y cualquier estado de historia que no se haya guardado. Preguntarse cuál es el dominio de una transición antes de escribirla es la forma más rápida de anticipar sus efectos colaterales.
XState no te entrega un conjunto de nodos: te entrega un valor anidado. Esa diferencia es puramente de codificación, y traducir en ambas direcciones es un ejercicio que conviene tener automatizado en la cabeza. Un estado atómico en la raíz se codifica como una cadena. Un compuesto activo se codifica como un objeto con una sola clave, la del compuesto, cuyo valor es la codificación de su hijo activo. Un paralelo se codifica como un objeto con tantas claves como regiones, todas presentes a la vez.
// La misma configuracion, en las dos representaciones.
// Conjunto formal: { raiz, Sesion, Panel, Detalle }
const valorXState = { sesion: { panel: 'detalle' } }
// Con una region paralela dentro de panel:
// Conjunto formal: { raiz, Sesion, Panel, Datos, Cargando, Editor, Abierto }
const conParalelo = { sesion: { panel: { datos: 'cargando', editor: 'abierto' } } }
// Y un estado atomico en la raiz, el caso degenerado:
// Conjunto formal: { raiz, Bloqueada }
const atomico = 'bloqueada'
La codificación es correcta precisamente por el teorema de la primera sección: como la configuración queda determinada por sus hojas y el camino hasta ellas es único, el objeto anidado no pierde información. Es, en el sentido estricto, un isomorfismo: se puede ir del conjunto al objeto y del objeto al conjunto sin ambigüedad ni pérdida, de modo que ambas representaciones son la misma cosa vista con dos notaciones. Y las tres reglas de consistencia se leen directamente en la forma del valor: una sola clave por nivel significa estado compuesto, varias claves hermanas significan estado paralelo, y la ausencia de claves huérfanas es el cierre bajo ancestros hecho sintaxis.
De ahí sale también la razón por la que comparar el valor con igualdad estricta es mala práctica y existe matches. Preguntar si el valor es exactamente { sesion: { panel: 'detalle' } } obliga a conocer la profundidad completa del árbol y rompe en cuanto alguien introduce un nivel intermedio o una región paralela nueva. Preguntar si la configuración contiene el estado sesion.panel.detalle es una consulta de pertenencia a un conjunto, que es la operación que la semántica realmente define y la única que sobrevive al refactor.
El mismo razonamiento explica por qué matches acepta rutas parciales. Preguntar por sesion.panel devuelve cierto tanto si está activo Lista como si lo está Detalle, porque la pregunta es si Panel pertenece a la configuración, y por cierre bajo ancestros pertenece siempre que alguno de sus hijos lo haga. Esa consulta por prefijos es exactamente lo que una interfaz suele necesitar —mostrar la barra lateral mientras estemos en cualquier lugar del panel— y es imposible de expresar con comparación de igualdad sin enumerar todas las hojas.
Y de la misma propiedad nace la utilidad de las etiquetas. Una etiqueta es un predicado sobre la configuración que no depende de la posición en el árbol: preguntar si algún estado activo lleva la marca de cargando responde a una pregunta transversal que ninguna ruta jerárquica captura, porque los estados de carga pueden vivir dispersos en varias ramas. Rutas y etiquetas son dos formas de consultar el mismo conjunto —una por estructura, otra por atributo— y elegir bien entre ellas es lo que mantiene las vistas desacopladas de la forma interna de la máquina.
Vale la pena detenerse en lo que estas tres reglas hacen, porque contienen el argumento completo a favor de las máquinas de estado y ese argumento suele contarse mal. La versión débil dice que un statechart es una forma ordenada de escribir condicionales, y con esa versión la herramienta parece un asunto de gusto: hay quien prefiere estructura y hay quien prefiere banderas booleanas. La versión fuerte, la correcta, es que las reglas de consistencia hacen que ciertas situaciones no puedan ser representadas en absoluto. En una configuración legal es imposible estar en dos hijos de un mismo compuesto, no porque un validador lo rechace en tiempo de ejecución, sino porque el conjunto que lo describiría no satisface la definición y ninguna operación del algoritmo puede producirlo: el conjunto de entrada se construye descendiendo por exactamente un hijo. Es imposible estar en un hijo sin estar en su padre, porque el cierre bajo ancestros es un invariante que cada microstep restablece por construcción. Y es imposible estar en un paralelo con una región dormida, porque entrar en él fuerza entrar en todas. Compara eso con cuatro banderas booleanas donde cargando y error pueden ser ambas ciertas: allí la corrección depende de que cada línea de código que las toca recuerde la regla no escrita, y basta un olvido en un manejador remoto para producir un estado que nadie pensó nunca. Aquí la corrección depende de la definición del objeto, y el olvido es literalmente inexpresable. Esa es la diferencia entre validar y construir bien, entre un sistema donde los estados imposibles se detectan y uno donde no existen; es la misma diferencia que hay entre comprobar que un puntero no es nulo y usar un tipo que no admite nulos. Cuando alguien pregunte por qué molestarse con toda esta maquinaria formal, la respuesta no es la elegancia ni los diagramas bonitos: es que las tres reglas de consistencia convierten una clase entera de errores en algo que el modelo no puede decir.
- Dibuja una máquina con dos niveles de anidamiento y una región paralela de dos ramas. Enumera a mano todas sus configuraciones legales y compara el total con el producto de las alternativas.
- Escribe una función que, dado el valor de XState, devuelva el conjunto plano de estados atómicos activos. Comprueba que la reconstrucción del subárbol completo es unívoca.
- Provoca deliberadamente una autotransición externa sobre un estado con
invokey observa la petición duplicada. Conviértela en interna y verifica que la invocación sobrevive. - Toma una transición cuyo origen y destino estén en ramas lejanas del árbol. Calcula a mano su ancestro compuesto común más bajo y predice qué
exity quéentryse ejecutarán antes de comprobarlo. - Añade un estado de historia profunda dentro de una región paralela, sal de ella y vuelve. Verifica que se restauran todas las ramas simultáneamente y explica por qué cualquier otra cosa produciría una configuración ilegal.
- Sustituye en un componente existente todas las comparaciones de igualdad del valor por llamadas a
matches. Después añade un nivel de anidamiento y comprueba cuáles de las dos versiones habría sobrevivido.