wandres.dev
LA FAMILIA UNIDIRECCIONAL · Redux, Elm, TCA, MVI

Qué comparten exactamente

La lección anterior dijo genoma; esta lo lee con lupa. Los tres genes se traducen en tres invariantes concretos que puedes verificar en cualquier miembro de la familia: una única fuente de verdad que concentra todo el estado, un hecho con nombre —acción, mensaje o intención— como único disparador del cambio, y un render que es función pura del estado. Aquí se enuncian con precisión, se rastrean en el store de Redux, el Model de Elm, el State de TCA y el UiState de MVI, y se muestra por qué los tres juntos forman una ecuación cerrada: si falta uno, ya no es la familia. La metáfora deja paso al criterio de verificación.

⏱ 17 min

La lección anterior nos dio la intuición: cuatro arquitecturas, un mismo genoma. Ahora toca cambiar la metáfora por el bisturí y enunciar con precisión qué comparten exactamente, hasta el punto de poder verificarlo en cualquiera de las cuatro sin margen de duda. Los tres genes se traducen en tres invariantes concretos: una única fuente de verdad que concentra todo el estado, un hecho con nombre como único disparador legítimo del cambio, y un render que no es más que una función pura del estado. No son tres características que se puedan adoptar por separado, sino tres cláusulas de un mismo contrato que, tomadas juntas, forman una ecuación cerrada. Verificar los tres es la prueba definitiva de pertenencia a la familia.

🎯 Al terminar esta lección sabrás
  • Enunciar la única fuente de verdad como invariante y reconocerla en el store, el Model, el State y el UiState.
  • Ver acción, mensaje e intención como el mismo concepto: el hecho con nombre, explícito y enumerable.
  • Formular el render como función pura del estado, vista = f(estado), común a las cuatro plataformas.
  • Verificar los tres invariantes en cualquier miembro de la familia y usarlos como prueba de pertenencia.

Una única fuente de verdad

El primer invariante es el más visible: todo el estado de una funcionalidad vive en un único valor, y no repartido en mil rincones que luego hay que reconciliar. Redux lo llama el árbol del store; Elm, el Model; TCA, el State; el Android moderno, el UiState. Cambia el nombre, no el concepto.

Este invariante es la herencia directa de la fuente única de verdad que viste en el primer nivel del track, llevada aquí a su extremo. Allí la regla era un dueño único por cada dato; aquí es un dueño único para todo el estado de la pantalla.

La duplicación, que era el bug más profundo, queda prohibida por construcción: si el dato solo existe en un sitio, no puede contradecirse consigo mismo. No hay dos copias de la sesión, del carrito o del formulario que puedan divergir mientras nadie mira.

Que el estado quepa en un valor acarrea consecuencias que no se notan hasta que faltan. Serializarlo entero es trivial, lo que abarata guardarlo en disco, enviarlo al servidor o adjuntarlo a un informe de error para reproducir el fallo exacto del usuario.

Tomar una instantánea coherente en un instante dado es leer una referencia, no recolectar fragmentos que podrían pertenecer a momentos distintos.

Y viajar en el tiempo —rebobinar, saltar a un punto del pasado— deja de ser un truco de magia y se vuelve un reduce sobre la lista de hechos, algo que solo es posible cuando existe una lista de hechos y un solo estado sobre el que plegarla.

Tiene, claro, un coste: concentrar el estado obliga a pensar dónde vive cada cosa y a pasar datos hacia abajo por el árbol de vistas. La familia acepta ese coste a cambio de la coherencia.

Por eso, cuando el estado es pequeño, herramientas más laxas resultan más cómodas y ganan adopción. Pero el invariante, cuando se sostiene, es lo que vuelve baratas operaciones que con el estado disperso serían imposibles: esa es la moneda con la que se paga la ceremonia.

El hecho con nombre: acciones, mensajes, intenciones

El segundo invariante es que el estado solo cambia en respuesta a un hecho con nombre, un dato que describe qué pasó y entra por un único sitio. Redux lo llama acción, con forma de objeto y un discriminante, { type, payload }. Elm lo llama mensaje, un Msg. TCA lo llama acción y usa un enum de Swift. MVI lo llama intención, un Intent, escrito como sealed interface.

Cuatro vocabularios para una misma pieza: un hecho explícito, nombrado y enumerable. La palabra clave es “describe qué pasó”, no “ordena qué hacer”.

Un buen hecho dice usuario/pulso-guardar, no guardarEnDisco. El qué-hacer es responsabilidad de la transición y de los efectos, no del hecho que los desencadena; confundirlos es el error más común de quien llega a la familia desde el mundo imperativo.

La naturaleza de tipo suma —unión en Elm, enum en TCA, sealed en Kotlin— no es un detalle sintáctico. Es lo que permite al compilador exigir que trates todos los casos, y lo que te da una lista cerrada de todo lo que puede ocurrirle a tu estado.

Esa lista es oro puro: es, literalmente, el índice de tu aplicación. Cuando un hecho no aparece en ella, no puede ocurrir; y cuando ocurre algo raro, sabes que fue uno de los que sí están, porque no hay otra puerta.

Como además cada hecho es un dato serializable, puedes registrarlos todos en orden y obtener una grabación exacta de la sesión.

Reproducir un bug se vuelve entonces reenviar la lista de hechos, sin adivinar. De ahí salen las DevTools de Redux, el depurador con viaje en el tiempo de Elm y la trazabilidad que hace de esta familia la más auditable que existe.

// El hecho con nombre como tipo suma: la forma que comparten
// el Msg de Elm, el Action de TCA y los Intent de MVI
type Accion =
  | { tipo: 'incrementado' }
  | { tipo: 'texto/cambiado'; valor: string }
  | { tipo: 'datos/pedidos' }
// Elm y TCA lo escriben como union o enum; Kotlin como sealed interface;
// Redux como objetos con un discriminante 'type'. Mismo concepto, otro acento.

Render como función del estado

El tercer invariante es que la vista es una función pura del estado: vista = f(estado). No manipulas la interfaz a mano en respuesta a eventos, sino que describes cómo debe verse la pantalla para un estado dado y dejas que el framework la reconcilie.

El DOM virtual de React, la view de Elm, el body de una vista SwiftUI y un composable de Jetpack Compose son, los cuatro, la misma idea con cuatro sintaxis. La interfaz es una proyección del estado, no un lugar donde el estado se guarda.

React:    function Vista(estado) { return <p>{estado.texto}</p> }
SwiftUI:  var body: some View { Text(state.texto) }
Compose:  @Composable fun Vista(estado: UiState) { Text(estado.texto) }

Este invariante es el que cierra el círculo y, no por casualidad, el que explica por qué el flujo unidireccional se impuso en tres plataformas —lo veremos en la última lección—.

Si la vista se deriva del estado, necesitas un único estado autoritario del que derivarla, y el círculo se muerde la cola: el render exige la fuente única, que exige el hecho con nombre, que exige la transición pura.

Y como la vista no guarda estado propio, nunca puede quedar desincronizada: no hay un dato escondido en un widget que a alguien se le olvidara actualizar, porque el widget no guarda nada, solo dibuja lo que el estado dice.

Aquí encaja además la inmutabilidad del primer gen. Un render que reconcilia necesita comparar el estado viejo con el nuevo para saber qué redibujar, y comparar es barato precisamente porque el estado es inmutable: si la referencia no cambió, nada cambió.

Los tres invariantes no son independientes; cada uno hace falta para que los otros dos sirvan de algo. Esta interdependencia es lo que convierte a los tres genes de la lección anterior en una sola pieza, y es la razón de que no puedas quedarte con dos.

La ecuación cerrada

Puestos en fila, los tres invariantes forman una ecuación de tres renglones: estado como el único valor, vista = f(estado) como la proyección, y estado siguiente = transicion(estado, hecho) como el único cambio, con f y transicion puras. Ahí cabe la familia entera.

Las ecuaciones no admiten adopción parcial, y esta tampoco. Quita la fuente única y el mismo hecho vivirá en dos sitios que se contradirán. Quita el hecho con nombre y el estado cambiará por vías que no podrás rastrear. Quita la pureza y perderás la reproducibilidad de la que cuelgan el viaje en el tiempo, el testing trivial y la hidratación en servidor.

Por eso una librería con dos de los tres invariantes no es un miembro tibio de la familia: es otra cosa. La prueba de pertenencia no es el parecido superficial ni el marketing, sino la verificación fría, invariante por invariante.

📦

Fuente única de verdad

Todo el estado en un valor: store, Model, State, UiState. Serializable, fotografiable, rebobinable. Sin duplicación que reconciliar.

🏷️

Hecho con nombre

El único disparador del cambio: acción, mensaje, intención. Explícito, enumerable, tipo suma. La lista cerrada de todo lo que puede pasar.

🖼️

Render como función

vista = f(estado). DOM virtual, view, body, composable. La interfaz proyecta el estado; no lo guarda ni puede desincronizarse.

flowchart LR
S[fuente unica de verdad] --> V[vista es funcion del estado]
V -->|usuario| A[hecho con nombre]
A --> U[transicion pura]
U --> S
style S fill:#cba6f7,color:#11111b
style A fill:#f9e2af,color:#11111b
style V fill:#a6e3a1,color:#11111b
style U fill:#89b4fa,color:#11111b
ℹ️
Mismo invariante, distinto nombre en cada plataforma

Merece la pena fijar el diccionario, porque el mayor obstáculo para ver la unidad es el vocabulario. La fuente única es store en Redux, Model en Elm, State en TCA y UiState en MVI. El hecho con nombre es acción, mensaje, acción e intención respectivamente. Y el render como función del estado es DOM virtual, view, body de SwiftUI y composable de Compose. Cuando traduzcas entre estas cuatro columnas, descubrirás que las cuatro documentaciones dicen lo mismo con palabras distintas.

Los tres invariantes son una ecuación, no una lista de deseos

La tentación del principiante es leer esto como una lista de buenas prácticas de la que puede tomar dos y dejar una. No lo es: es una ecuación, y las ecuaciones no negocian. El caso más instructivo es Zustand, la librería que en 2026 destronó a Redux en la web. Zustand tiene fuente única —un store— y render como función del estado, pero por defecto no impone el hecho con nombre: puedes mutar el store desde cualquier parte llamando a set con lo que quieras, sin declarar antes qué acción ocurrió. Le falta, por tanto, un invariante de los tres, y esa ausencia lo saca de la familia unidireccional estricta aunque se le parezca muchísimo. Esto no es un defecto de Zustand: es una elección deliberada de cambiar una garantía por menos ceremonia, y para el estado pequeño de la app mediana suele ser el trato correcto. Pero conviene saber qué estás cambiando. Cuando escribes Zustand con acciones nombradas y un solo punto de mutación, estás reintroduciendo a mano el invariante que faltaba, y recuperas la familia sin la ceremonia de Redux. La lección profunda es que la pertenencia a la familia no la decide el logotipo de la librería, sino la presencia verificable de los tres invariantes en tu código. Aprende a hacer esa verificación fría y dejarás para siempre de guiarte por nombres de moda: mirarás el código, contarás hasta tres, y sabrás exactamente qué garantías tienes y cuáles has cambiado por comodidad.

⚔️ Verifica los tres invariantes en una arquitectura concreta
  1. Toma una funcionalidad real y localiza su fuente única de verdad: si encuentras el mismo dato en dos sitios, el primer invariante ya está roto.
  2. Enumera todos los hechos con nombre que puede recibir esa funcionalidad y comprueba que forman una lista cerrada, no un conjunto abierto de llamadas sueltas.
  3. Escribe su render como una función del estado y verifica que no guarda ningún estado propio que el framework no pueda reconstruir.
  4. Aplica la prueba de pertenencia a Zustand, Jotai o Signals y decide, invariante por invariante, cuál falta y por qué.
  5. Reintroduce a mano el invariante que faltaba en esa librería y observa cuánta garantía recuperas por cuánta ceremonia.
  6. Escribe la ecuación de tres renglones para una app tuya y pégala como comentario de cabecera: es su contrato.