La FSM a mano: veinte líneas y cero dependencias
El nivel de las alternativas empieza estableciendo el suelo: cuánto cuesta no instalar nada. Esta lección construye las dos escrituras canónicas de la función de transición —el switch exhaustivo que el compilador verifica y la tabla como dato inerte— y las estira hasta su límite real, con guardas, acciones de entrada, suscripción, serialización y detección automática de estados inalcanzables. Después mide el techo con precisión, porque el argumento del cero dependencias solo es honesto si sabes nombrar una por una las capacidades a las que estás renunciando.
Un nivel titulado alternativas a XState solo puede empezar de una manera: fijando el precio del suelo. Antes de comparar motores conviene saber exactamente cuánto cuesta no usar ninguno, y la respuesta incomoda a los dos bandos: unas veinte líneas de TypeScript que el compilador vigila mejor de lo que vigilaría cualquier objeto de configuración. Lo que sigue no es minimalismo nostálgico ni provocación; es la línea base contra la que se mide todo lo demás en las cuatro lecciones siguientes. Cada dependencia que instales a partir de aquí tendrá que justificarse contra este código —no contra el caos de booleanos correlacionados que ya diste por superado en el nivel uno— y esa comparación exigente es justamente la que casi nadie hace.
- Escribir la función de transición en sus dos formas canónicas y saber qué propiedad conserva cada una.
- Añadir guardas, acciones de entrada y suscripción sin abandonar el código a mano.
- Explotar la tabla como dato: recorrerla, auditarla y generar casos de prueba desde ella.
- Nombrar el techo con capacidades concretas y no con intuiciones vagas sobre complejidad.
Dos escrituras de la misma función
Formalmente, δ es una función parcial de S × Σ en S. A mano tienes dos maneras de materializarla, y la elección entre ellas no es estilística: decide qué puede hacer el resto del sistema con tu máquina. La primera la escribe como código, ramificando primero por estado y después por evento.
type Estado = 'inactivo' | 'cargando' | 'listo' | 'fallo'
type Evento = { type: 'PEDIR' } | { type: 'RESOLVER' } | { type: 'RECHAZAR' } | { type: 'REINTENTAR' }
function imposible(x: never): never {
throw new Error(`estado no cubierto: ${String(x)}`)
}
export function transicion(estado: Estado, evento: Evento): Estado {
switch (estado) {
case 'inactivo': return evento.type === 'PEDIR' ? 'cargando' : estado
case 'cargando': return evento.type === 'RESOLVER' ? 'listo' : evento.type === 'RECHAZAR' ? 'fallo' : estado
case 'listo': return evento.type === 'PEDIR' ? 'cargando' : estado
case 'fallo': return evento.type === 'REINTENTAR' ? 'cargando' : estado
default: return imposible(estado)
}
}
La rama default con el parámetro never no es adorno: convierte al compilador en un verificador de totalidad. El día que añadas un quinto estado al tipo, esa línea deja de compilar y te obliga a decidir qué hace la máquina en él. Ningún motor te da esa garantía en tiempo de compilación con esta contundencia, porque en un objeto de configuración los estados son claves de datos y no ramas que el analizador de flujo pueda contar.
La segunda escritura convierte δ en dato: un objeto donde cada celda existente es una transición legal y cada celda ausente es un evento ignorado.
type Tabla = Record<Estado, Partial<Record<Evento['type'], Estado>>>
export const tabla = {
inactivo: { PEDIR: 'cargando' },
cargando: { RESOLVER: 'listo', RECHAZAR: 'fallo' },
listo: { PEDIR: 'cargando' },
fallo: { REINTENTAR: 'cargando' },
} satisfies Tabla
La diferencia entre ambas es exactamente la que separa a las librerías ligeras de los motores completos, y por eso conviene entenderla aquí, en pequeño. El switch es opaco: solo se puede ejecutar. La tabla es transparente: se puede ejecutar, recorrer, serializar, dibujar, difundir en un control de cambios y comparar entre dos versiones del código. La propiedad que hace posible el diagrama que es el código no la aporta ninguna librería; la aporta haber escrito la definición como estructura y no como control de flujo.
La comprobación de exhaustividad con never es verificación formal barata, del mismo linaje que los métodos que estudiaste al hablar de semántica: no demuestra que tu modelo sea correcto, pero sí que es total —que ningún par estado-evento cae en el vacío sin que alguien lo haya decidido—. Cuando delegas en un motor, ese análisis se traslada del compilador a las pruebas y al inspector. Es un cambio de garantía estática por garantía dinámica que casi nunca se contabiliza en las comparativas de peso.
Lo que cabe en veinte líneas
El escepticismo habitual sostiene que a mano solo se llega a lo trivial. Es falso, y comprobarlo importa: guardas, acciones de entrada y suscripción caben sin despeinarse en un intérprete diminuto.
type Celda = Estado | { destino: Estado; cond?: (ctx: Ctx) => boolean }
type Ctx = { intentos: number }
export function crearMaquina(inicial: Estado, ctx: Ctx) {
let estado = inicial
const oyentes = new Set<(e: Estado) => void>()
const entrada: Partial<Record<Estado, (c: Ctx) => void>> = {
cargando: (c) => { c.intentos += 1 },
}
return {
get estado() { return estado },
enviar(tipo: Evento['type']) {
const celda = (tabla as Record<Estado, Record<string, Celda>>)[estado][tipo]
if (!celda) return estado // evento inaplicable: quieto
const destino = typeof celda === 'string' ? celda : celda.destino
if (typeof celda !== 'string' && celda.cond && !celda.cond(ctx)) return estado
estado = destino
entrada[estado]?.(ctx) // accion de entrada estilo Moore
oyentes.forEach((f) => f(estado))
return estado
},
suscribir(f: (e: Estado) => void) { oyentes.add(f); return () => oyentes.delete(f) },
}
}
Totalidad verificada
El switch exhaustivo garantiza que ningún estado queda sin tratar. Es una prueba estática, no una convención.
Definición como dato
La tabla se recorre, se serializa y se compara. Es la condición previa de cualquier herramienta de visualización.
Guardas y efectos
Una celda que en vez de destino es objeto con condición cubre el reintento con límite sin ninguna dependencia.
Superficie cero
Ni versiones, ni cambios de API, ni auditoría de suministro. El código que no instalas no se rompe ni te vigila.
stateDiagram-v2 [*] --> inactivo inactivo --> cargando : PEDIR cargando --> listo : RESOLVER cargando --> fallo : RECHAZAR listo --> cargando : PEDIR fallo --> cargando : REINTENTAR
Que la definición sea dato paga su primer dividendo en las pruebas. Con quince líneas más recorres el grafo en anchura y detectas estados inalcanzables, que son el defecto de modelado más frecuente y el más invisible: nadie escribe un test para un estado al que nunca se llega.
export function alcanzables(t: Tabla, inicial: Estado): Set<Estado> {
const vistos = new Set<Estado>([inicial])
const cola: Estado[] = [inicial]
while (cola.length > 0) {
const actual = cola.shift() as Estado
for (const destino of Object.values(t[actual]) as Estado[]) {
if (!vistos.has(destino)) { vistos.add(destino); cola.push(destino) }
}
}
return vistos // compara con Object.keys de la tabla
}
Un test de tres líneas que compare alcanzables con las claves de la tabla convierte un defecto de diseño silencioso en un fallo de integración continua. Añade otro que recorra cada par estado-evento y verifique que el destino existe en la tabla, y ya tienes la mitad de lo que el análisis estático de un motor te habría dado. Esto es lo que casi nadie hace cuando escribe la máquina a mano, y su ausencia —no la falta de potencia— es lo que suele condenar el enfoque.
El techo, medido en ausencias
Un techo honesto se enumera, no se insinúa. Estas son las capacidades que a mano cuestan mucho más de lo que ahorran, y ninguna de ellas aparece hasta que tu problema la pide de verdad.
| Capacidad | A mano | Motor completo |
|---|---|---|
| Estados anidados y jerarquía | reescritura del intérprete | nativa |
| Regiones paralelas ortogonales | producto cartesiano manual | nativa |
| Transiciones diferidas por tiempo | temporizadores sueltos que hay que limpiar | declarativas |
| Historia superficial y profunda | pila de estados a mano | nativa |
| Actores invocados con ciclo de vida | cancelación manual propensa a fugas | por estado |
| Visualización y viaje en el tiempo | inexistente | inspector y estudio |
| Pruebas basadas en modelo | generador propio | recorrido del grafo |
Las tres primeras filas son las que marcan la frontera real. Emular jerarquía a mano significa reimplementar el algoritmo de resolución de transiciones que estudiaste al hablar de semántica formal, incluida la selección del descriptor más profundo y el orden de salidas y entradas. Emular regiones paralelas significa aceptar el producto cartesiano de estados que Harel vino a eliminar. En cuanto una de esas dos aparece, el código a mano deja de ser una alternativa barata y se convierte en un motor mal escrito por ti.
Hay además un coste que no cabe en la tabla porque no es técnico sino social: el segundo lector. Tu intérprete de veinte líneas es transparente para ti y ligeramente ajeno para cualquier otra persona, que tendrá que leerlo entero antes de tocar una transición. Un motor conocido traslada ese coste a documentación pública y conocimiento previo. La pregunta no es si tu código es bueno, sino cuántas personas van a tener que entenderlo y cuántas veces.
El fracaso típico del enfoque a mano no es quedarse corto de golpe, sino crecer por acumulación silenciosa: primero una guarda, luego un temporizador, luego un objeto anidado que hace de subestado, luego una bandera para recordar de dónde venías. Ninguna adición parece cara y el conjunto acaba siendo un motor sin nombre, sin pruebas propias, sin documentación y con un solo experto vivo. Si te descubres escribiendo la palabra padre o la palabra región dentro de tu intérprete, ya cruzaste el techo hace semanas.
La lectura perezosa de esta lección es que las máquinas a mano bastan casi siempre, y es exactamente la mitad equivocada del argumento. La lectura correcta es que el código que acabas de escribir no es un competidor de XState sino la vara con la que se mide a XState, a Robot, a Zag y a cualquier cosa que aparezca después. Una comparación entre librerías que no incluya el cero como opción es propaganda, porque toda librería gana contra otra librería y ninguna gana automáticamente contra veinte líneas verificadas por el compilador. Y aquí está la asimetría que casi nunca se dice en voz alta: el coste de la dependencia es continuo —versiones, migraciones, superficie de API, peso, curva para cada persona nueva— mientras que su beneficio es escalonado, porque aparece de golpe el día que necesitas jerarquía, paralelismo, actores o inspección y no antes. Un coste continuo contra un beneficio escalonado significa que existe un tramo, más largo de lo que la industria admite, donde no instalar nada es estrictamente superior. Saber dónde termina ese tramo es la destreza que este nivel entero persigue, y solo la tiene quien ha escrito el suelo con sus manos. Quien nunca lo escribió no está eligiendo entre alternativas: está eligiendo entre las opciones que alguien más le presentó como tales.
- Toma una máquina que tengas hoy en una librería y reescribe su versión plana a mano con el
switchexhaustivo. Cronometra cuánto tardas de verdad. - Convierte esa misma máquina en tabla de datos y comprueba si alguna transición desaparece o aparece: las discrepancias son defectos de modelado que la librería estaba tapando.
- Añade la comprobación con
nevery borra un estado del tipo. Verifica que el compilador te lo impide antes de ejecutar nada. - Implementa
alcanzablessobre tu tabla y busca estados a los que nunca se llega. Documenta cada uno como decisión consciente o bórralo. - Escribe la lista exacta de capacidades de la tabla del techo que tu problema usa hoy. Si son cero, tienes una dependencia que no te está pagando el alquiler.
- Cuenta cuántas personas del equipo tendrían que leer tu intérprete para cambiar una transición, y súmalo al lado del coste del enfoque a mano.