wandres.dev
NIVEL DIOS: SÍNTESIS · pensar en máquinas

La máquina dentro de una arquitectura mayor: Redux, TCA, señales y servidor

Una máquina de estado no es una arquitectura y no compite con Redux, con TCA, con las señales ni con la cache del servidor: ocupa un hueco muy concreto dentro de cualquiera de ellas, el del protocolo de un flujo. Esta lección demuestra que un reductor es formalmente una función de transición sin restricción de legalidad, cataloga los tres patrones de integración con un contenedor unidireccional, fija la dirección obligatoria del flujo entre señales y máquinas, y cierra con el contrato de cinco cláusulas que separa la máquina de la capa de datos.

⏱ 21 min

La pregunta llega siempre en el mismo formato y siempre está mal planteada: ¿usamos máquinas de estado o usamos Redux? La disyuntiva supone que ambas cosas ocupan el mismo hueco, y no lo hacen. Un contenedor unidireccional responde a la pregunta de dónde vive el estado de la aplicación y quién tiene permiso para cambiarlo; una máquina responde a la pregunta de qué secuencias de cambio son legales. Son preguntas ortogonales, y de hecho la respuesta más frecuente en sistemas maduros es las dos a la vez: un contenedor que aloja el estado y, dentro de él, máquinas que gobiernan los flujos donde el orden importa. Entender por qué eso no es redundancia sino división del trabajo exige mirar la relación formal entre un reductor y una función de transición, que es más estrecha de lo que casi nadie sospecha.

🎯 Al terminar esta lección sabrás
  • Situar la máquina en el hueco exacto que ocupa dentro de una arquitectura mayor, sin plantearla como alternativa.
  • Demostrar la relación formal entre un reductor unidireccional y la función de transición de un autómata.
  • Elegir entre los tres patrones de integración con un contenedor según qué propiedad se quiera conservar.
  • Fijar la dirección del flujo con señales y el contrato de frontera con la cache del servidor.

Un reductor es una función de transición sin portero

Escribe la firma de un reductor de Redux y la firma de una función de transición una debajo de otra y mira lo que pasa.

// Reductor unidireccional
type Reducer<S, A> = (estado: S, accion: A) => S

// Funcion de transicion de un automata
type Delta<Q, Sigma> = (estado: Q, simbolo: Sigma) => Q

Son el mismo tipo. No parecido: el mismo. Un store de Redux es literalmente un autómata cuyo conjunto de estados es el conjunto de todos los valores posibles del árbol de estado, cuyo alfabeto es el conjunto de tipos de acción y cuya función de transición es el reductor raíz. La diferencia no está en la forma, está en una sola propiedad ausente: el reductor no restringe qué acciones son legales en qué estados. Toda acción es aceptable en todo momento y el reductor decide qué hacer con ella, normalmente con una cadena de condicionales que reconstruye a mano la legalidad que el tipo no expresa.

Ahí está todo el aporte de una máquina, y conviene enunciarlo sin adornos: una máquina de estado es un reductor con portero. La legalidad deja de ser una comprobación dispersa dentro del cuerpo de la función y pasa a ser estructura del tipo, declarada por adelantado, enumerable, dibujable y recorrible. El precio también es único y hay que pagarlo con los ojos abiertos: hay que enumerar los modos, y enumerar cuesta.

De esa identidad se sigue la regla de asignación de huecos que ordena todo lo demás. El contenedor —Redux, TCA, un store de señales— responde por la custodia: dónde vive el dato, quién puede escribirlo, cómo se observa el cambio. La máquina responde por el protocolo: qué secuencias de eventos son admisibles y qué ocurre en cada una. Y la cache del servidor responde por la verdad: qué es cierto ahora mismo según el sistema que manda. Tres preguntas distintas, tres responsables, y ningún solapamiento necesario.

Preocupación Contenedor unidireccional Máquina de estado Cache de servidor
Dónde vive el dato es su razón de ser solo el contexto propio por clave, fuera de la vista
Qué cambios son legales lo decide el cuerpo del reductor lo declara la estructura no aplica
Efectos y asincronía middleware o efectos declarados invoke con ciclo de vida reintentos y revalidación
Composición un árbol de reductores actores que se engendran claves e invalidación
Qué se puede demostrar poco, el espacio es abierto alcanzabilidad y cobertura frescura, no comportamiento
ℹ️
TCA ya es una máquina, solo que a medio declarar

La Composable Architecture de Point-Free merece una nota aparte porque es el caso donde la confusión es mayor. Su reductor tiene exactamente la firma anterior y su sistema de efectos es equivalente a invoke. Añadir una librería de statecharts encima de TCA es casi siempre un error: lo que hay que hacer es modelar el State como una enumeración con valores asociados en lugar de como una estructura de banderas. En cuanto el estado deja de ser una estructura con cuatro opcionales y pasa a ser una enumeración de cuatro casos, el compilador de Swift se convierte en el portero y TCA se comporta como un statechart con verificación en compilación. El formalismo no siempre llega en forma de librería.

Tres formas de convivir con un contenedor

Cuando ya hay un contenedor en el proyecto, la máquina puede colocarse de tres maneras y la elección se hace por lo que se quiera conservar, no por gusto.

📦

La máquina dentro del store

El valor de estado de la máquina es una porción del árbol y cada evento se despacha como acción. Conserva las herramientas del contenedor: un solo registro, un solo viaje en el tiempo.

🔭

El store dentro de la máquina

La máquina observa el store como un actor observable y reacciona a sus cambios como a eventos. Conserva la autonomía de la máquina y su capacidad de vivir fuera de la vista.

🌉

Vecinos con un puente

Ambos son autónomos y un módulo delgado traduce en las dos direcciones. Conserva la posibilidad de retirar cualquiera de los dos sin reescribir el otro.

🚷

Y la cuarta, que no existe

Duplicar el mismo dato en los dos sitios y sincronizarlo con efectos. No es un patrón, es la avería que los tres anteriores existen para evitar.

El puente es el que más se usa en sistemas grandes y el que menos se escribe bien, así que conviene verlo. La regla es que el puente traduce, nunca decide: si el módulo de traducción contiene un condicional sobre el dominio, ese condicional pertenecía a la máquina.

// El puente: una direccion son eventos, la otra son acciones. Nada mas.
import { createActor } from 'xstate'
import { store } from './store'
import { checkout } from './checkout'

const actor = createActor(checkout).start()

// Maquina hacia contenedor: cada cambio de modo se publica como accion.
let anterior: unknown = null
actor.subscribe((snap) => {
  if (snap.value !== anterior) {
    anterior = snap.value
    store.dispatch({ type: 'checkout/modo', payload: snap.value })
  }
})

// Contenedor hacia maquina: solo lo que la maquina necesita para decidir.
store.subscribe(() => {
  const carrito = store.getState().carrito
  actor.send({ type: 'CARRITO_CAMBIO', lineas: carrito.lineas.length })
})

Hay un detalle de esa suscripción que decide si el sistema es estable o es un bucle: la comparación contra el valor anterior. Sin ella, cada publicación al contenedor provoca una notificación que provoca un evento que provoca una transición que provoca una publicación. Los puentes entre dos sistemas reactivos siempre necesitan un punto de idempotencia, y la práctica sana es que ese punto esté explícito y comentado, porque es lo primero que alguien borra por parecer redundante.

La dirección obligatoria con las señales

Con reactividad de grano fino la integración parece trivial y esconde una trampa de diseño que produce fallos difíciles de reproducir. La regla es una y no admite excepciones prácticas: las señales leen la máquina; la máquina no lee las señales.

La razón es de naturaleza de los dos modelos. Una señal empuja: cuando su valor cambia, notifica a quien dependa de ella, y lo hace de forma síncrona dentro de un ciclo de propagación. Una máquina tira: consume eventos de una cola, uno a uno, y cada uno produce un macrostep completo antes de atender el siguiente. Si conectas una señal directamente a la entrada de una máquina, estás inyectando eventos en mitad de la propagación de otro sistema, y con ello reintroduces todo lo que la cola de eventos existía para eliminar: reentrada, orden indeterminado y transiciones que ocurren mientras otra está a medias.

La forma correcta tiene tres piezas. Los eventos entran por acciones explícitas del usuario o del mundo, nunca por derivación. La vista lee la máquina a través de un selector, de modo que un componente solo se recalcula cuando cambia la parte concreta que mira. Y todo lo derivable se deriva en la capa de señales, fuera del contexto, porque un derivado guardado en el contexto es una copia que puede quedar desincronizada y además ensucia el espacio de estados.

// La senal lee la maquina. Nunca al reves.
const modo = createMemo(() => snapshot().value)
const puedePagar = createMemo(() => modo() === 'listo' && lineas().length > 0)

// Derivado en la capa reactiva, no en el contexto de la maquina.
const totalConImpuestos = createMemo(() =>
  lineas().reduce((suma, l) => suma + l.precio * l.cantidad, 0) * 1.21
)

// El unico camino de vuelta pasa por un evento explicito y nombrado.
const confirmar = () => actor.send({ type: 'CONFIRMAR' })

La consecuencia de granularidad no es menor en sistemas grandes. Si la vista se suscribe al snapshot completo, cualquier escritura en el contexto —un contador de intentos, una marca de tiempo— repinta componentes que solo miraban el modo. Suscribirse por selector convierte el coste de observar una máquina en proporcional a lo que cada componente realmente lee, y es lo que hace viable tener decenas de actores vivos sin que el rendimiento se note.

⚠️
El efecto que reenvía a la máquina lo que la máquina acaba de decir

El anti-patrón concreto tiene una firma reconocible: un efecto que observa un valor derivado del propio estado de la máquina y, cuando cambia, le envía un evento. Parece una automatización elegante y es un lazo de realimentación con retardo indeterminado. Si algo debe ocurrir automáticamente al entrar en un estado, eso es una acción de entrada o una transición always, y ambas se resuelven dentro del mismo macrostep, de forma determinista y visible en el diagrama. Cualquier automatismo que salga de la máquina y vuelva por la puerta de atrás es comportamiento que ya no está en el modelo.

El contrato de frontera con el servidor

La última frontera es la más disputada y la que más código innecesario genera. Se resuelve con una división de responsabilidades de una sola línea: la máquina modela el protocolo, la cache modela la verdad. El protocolo es qué se puede hacer y en qué orden; la verdad es qué valor tiene ahora mismo un recurso que vive en otro sistema. Confundirlas produce las dos averías clásicas: una máquina que reimplementa una cache, y una cache a la que se le pide que recuerde en qué paso de un asistente va el usuario.

flowchart TD
A[Vista: lee por selector y emite eventos] --> B[Maquina: protocolo y legalidad]
B --> C[Actor invocado: llama a la capa de datos]
C --> D[Cache de servidor: verdad por clave]
D --> E[Servidor]
D -.revalida sola.-> D
B --> F[Contexto: identidad instantaneas e historia del flujo]
A -.lee derivados.-> D
style B fill:#89dceb,color:#11111b
style D fill:#a6e3a1,color:#11111b
style F fill:#f9e2af,color:#11111b

Nótese en el diagrama la flecha discontinua que va de la vista directamente a la cache. No es un atajo tolerado a regañadientes: es la forma correcta de leer lo que solo se muestra. Obligar a que todo dato del servidor entre por el contexto de la máquina convierte al grafo en un intermediario que copia sin aportar, y cada copia que cruza esa frontera es una oportunidad de divergencia que nadie pidió.

El contrato tiene cinco cláusulas y cada una elimina una clase entera de errores. Primera: el contexto no guarda copias de lo que la cache posee; guarda identificadores, instantáneas con valor contractual e historia del procedimiento. Segunda: la máquina no hace peticiones, invoca actores que las hacen; así el ciclo de vida de la petición es el del estado y la cancelación es automática al salir. Tercera: la señal de cancelación se propaga de verdad hasta la petición, porque un actor que se detiene mientras su promesa sigue en vuelo es una fuga con nombre propio. Cuarta: los errores se clasifican en la frontera y no dentro, de modo que la máquina reciba un fallo de red, uno de validación y un rechazo de negocio como tres eventos distintos y no como uno genérico. Quinta: la vista puede leer directamente de la cache lo que solo va a mostrar; obligar a que todo pase por el contexto convierte la máquina en un cuello de botella y en una copia.

Ninguna arquitectura es una alternativa a otra hasta que se sabe qué pregunta responde cada una

Los debates de arquitectura son improductivos con una regularidad que debería haber hecho sospechar a la profesión hace tiempo, y la causa no es el fanatismo sino un error de tipo: se comparan cosas que no son comparables porque responden a preguntas distintas. Redux responde a dónde vive el dato y cómo se audita su cambio. Una máquina responde a qué secuencias son admisibles. Una señal responde a cómo se propaga eficientemente un cambio hasta quien lo mira. Una cache de servidor responde a cuándo lo que tengo dejó de ser cierto. Cuatro preguntas ortogonales, cuatro respuestas que pueden coexistir en el mismo archivo sin rozarse. Cuando un equipo discute si adoptar máquinas de estado y la conversación deriva a si Redux es mejor, lo que está ocurriendo es que nadie ha escrito la lista de preguntas, y sin esa lista cualquier herramienta parece invadir el terreno de cualquier otra. La disciplina que hay que adquirir, y que vale para tecnologías que aún no existen, es preguntar de cada pieza qué pregunta responde y qué preguntas deja explícitamente sin responder; una pieza que dice responderlas todas está mintiendo o va a obligarte a resolver mal tres de las cuatro. Esto tiene una consecuencia práctica que ahorra años: la mayoría de los sistemas que sufren de complejidad accidental no sufren por haber elegido mal una herramienta, sino por haber pedido a una herramienta que respondiera una pregunta ajena. Un contexto de máquina que actúa de cache, un store global que actúa de protocolo, una cache que actúa de estado de interfaz. En los tres casos la herramienta era buena y el hueco era otro, y el coste no se paga el día de la decisión sino durante todos los años en que alguien tiene que entender por qué el dato correcto está en el sitio equivocado.

⚔️ Sitúa tus máquinas en la arquitectura que ya tienes
  1. Escribe las cuatro preguntas —custodia, protocolo, propagación y verdad— y anota qué pieza de tu sistema responde cada una hoy. Marca las que responden dos.
  2. Toma un reductor grande de tu contenedor y cuenta cuántos de sus condicionales existen solo para comprobar si la acción era legal ahora. Ese recuento es la máquina que tienes escondida.
  3. Identifica qué patrón de integración usas sin haberlo decidido y comprueba si conserva la propiedad que de verdad te importa.
  4. Busca en tus puentes el punto de idempotencia. Si no lo encuentras, tienes un bucle latente esperando una latencia mala.
  5. Localiza cualquier efecto que observe estado derivado de la máquina y le devuelva eventos, y conviértelo en acción de entrada o en transición always.
  6. Audita el contexto contra las cinco cláusulas del contrato y saca de él todo lo que sea copia de algo cuya verdad vive en la cache.