wandres.dev
TCA VS ALTERNATIVAS · el juicio honesto

TCA frente a `Redux` y `Elm`: qué comparten y qué añade el sistema de tipos

TCA no inventó el flujo unidireccional y nunca lo pretendió: es la arquitectura de `Elm` con la nomenclatura de `Redux`, trasplantada a un lenguaje con tipos algebraicos, semántica de valor y concurrencia estructurada. Esta lección separa con precisión lo que es herencia de lo que es aportación, comparando pieza por pieza el estado, las acciones, la composición y los efectos, y se detiene en lo que solo Swift permite: rutas de casos, agotamiento comprobado con cargas útiles y cancelación tipada. Termina con lo que TCA perdió por el camino y que el ecosistema web sí tiene.

⏱ 21 min

Hay una forma perezosa de elogiar a TCA que consiste en llamarla original, y una forma perezosa de despacharla que consiste en llamarla Redux para Swift. Las dos fallan por el mismo motivo: tratan la genealogía como si fuera un juicio de valor. TCA desciende de la arquitectura de Elm de manera directa y confesa, toma prestado de Redux casi todo su vocabulario, y su contribución propia no está en el ciclo —que es el mismo desde 2014— sino en lo que ocurre cuando ese ciclo se escribe en un lenguaje con tipos algebraicos, semántica de valor garantizada y concurrencia estructurada. Esta lección hace la disección con cuidado, porque entender qué parte es herencia y qué parte es aportación es lo que permite llevarse las ideas a cualquier plataforma sin llevarse la superstición.

🎯 Al terminar esta lección sabrás
  • Trazar la genealogía real de TCA y distinguir qué toma de Elm y qué de Redux.
  • Explicar qué garantiza el sistema de tipos de Swift que el ecosistema web solo puede pedir por convención.
  • Comparar la composición por combinación de reducers con la composición por rebanado de dominios.
  • Reconocer qué tiene el ecosistema web que TCA no tiene, y por qué esas ausencias sí importan.

La genealogía: Elm primero, Redux después

La arquitectura de Elm fijó las cuatro piezas antes que nadie: un modelo que contiene todo el estado, un tipo de mensajes que enumera todo lo que puede pasar, una función de actualización que toma modelo y mensaje y devuelve modelo nuevo más un comando, y un runtime que ejecuta ese comando y realimenta el resultado como mensaje. Redux popularizó ese ciclo en JavaScript renunciando a la cuarta pieza: su reducer devuelve solo el estado, y los efectos quedaron fuera del modelo, delegados a middleware.

TCA es, con precisión inusual, la arquitectura de Elm en Swift, con la nomenclatura de Redux en la superficie. El Effect devuelto por el reducer es el Cmd de Elm, no un middleware. Los efectos de larga vida que emiten varias acciones son las suscripciones. El árbol de estado con rebanado hacia los hijos es la composición de Elm. Y la insistencia en que el reducer sea una función pura viene de un lenguaje donde la pureza no es una recomendación sino la única opción.

Pieza Elm Redux TCA
Estado Model objeto congelado struct State
Evento Msg objeto con campo de tipo enum Action
Lógica update reducer Reduce
Efecto Cmd middleware externo Effect devuelto
Suscripción Sub middleware externo efecto de larga vida
📝
El detalle que más se copia mal

Lo que hace singular a Elm y a TCA no es que el reducer sea puro, sino que el efecto sea el valor devuelto por esa función pura. En Redux clásico el efecto ocurre fuera y luego despacha, con lo que la relación entre una acción y su consecuencia asíncrona no está en ningún tipo: es un acuerdo. Ese único detalle explica por qué el TestStore puede afirmar los efectos y por qué en Redux hay que testear el middleware aparte.

Lo que añade el sistema de tipos de Swift

Aquí está la aportación real, y es más profunda que la sintaxis. Cuatro cosas que en Elm existen a medias y en Redux no existen en absoluto.

La primera es la semántica de valor por defecto. En Redux la inmutabilidad es una promesa que el equipo hace y que hay que sostener con librerías de copia o con congelación en desarrollo; nada impide mutar el estado dentro de un reducer, y cuando ocurre, el fallo se manifiesta como una vista que no se refresca. En Swift una struct se copia al asignarse, punto, y la mutación intencionada se marca con inout en la firma del reducer. La disciplina más difícil de Redux es gratis y obligatoria.

La segunda es el agotamiento con cargas útiles. Un enum de Swift con valores asociados es una suma etiquetada de verdad: cada caso lleva datos de tipo distinto y el switch obliga a cubrirlos todos. En Redux una acción es un objeto con un campo de texto que dice su tipo, y aunque las uniones discriminadas de TypeScript se acercan mucho, la comparación pieza a pieza es instructiva.

// El agotamiento hay que pedirlo con un truco, y solo cubre la etiqueta
type Accion =
  | { tipo: "buscar"; texto: string }
  | { tipo: "respuesta"; items: Item[] };

function jamas(x: never): never { throw new Error("caso no cubierto"); }
// El agotamiento es del lenguaje, y el emparejamiento llega hasta la carga util
enum Accion {
  case buscar(texto: String)
  case respuesta(Result<[Item], any Error>)
}

switch accion {
case .buscar(let texto) where texto.count > 2: ...
case .respuesta(.success(let items)): ...
}

La diferencia no es cosmética. En la versión de arriba el compilador solo sabe comprobar que cubriste todas las etiquetas si alguien recuerda invocar el truco; en la de abajo lo comprueba siempre, y además permite ramificar sobre el contenido, que es justo lo que hace legible un reducer con quince casos.

La tercera es la más específica y la menos conocida fuera del ecosistema: las rutas de casos. Una ruta de clave describe cómo llegar a una propiedad de una struct; una ruta de caso hace lo mismo para un caso de un enum, y permite tratarlo como un lugar al que apuntar, escribir y rebanar. Sin esa pieza no existirían ifCaseLet, la presentación como dato ni el enrutado de acciones anidadas. En la web no hay equivalente porque no hay nada a lo que apuntar.

// Rebanar un caso de un enum como si fuera una propiedad
Reduce { state, action in ... }
  .ifCaseLet(\.editando, action: \.editor) {
    Editor()
  }

// Y en la vista, el mismo valor sirve como binding de presentacion
.sheet(item: $store.scope(state: \.destino?.editor, action: \.destino.editor)) { hijo in
  EditorView(store: hijo)
}

La cuarta es la concurrencia estructurada. Un Effect es un valor que envuelve una tarea con un ámbito y un identificador de cancelación comprobado; cancelar es una operación del lenguaje, no una bandera. En Redux, cancelar el trabajo en vuelo exige elegir una librería de middleware que lo modele, y cada una lo hace de forma incompatible con las demás.

Composición: combinar frente a rebanar

Redux compone por combinación: se tienen varios reducers que gestionan trozos del estado y una función los une bajo un objeto raíz. Es composición horizontal y tiene una propiedad muy notable, que es que toda acción llega a todos los reducers. Eso hace triviales las respuestas transversales y hace difícil razonar sobre quién manejó qué.

TCA compone por rebanado: el padre declara el estado del hijo como una propiedad y las acciones del hijo como un caso, y Scope construye una vista de escritura sobre ese trozo. Es composición vertical y jerárquica, y su propiedad característica es la contraria: una acción del hijo llega al padre por un canal explícito y con nombre, de modo que el padre puede reaccionar, transformar o ignorar. La comunicación entre hermanos no existe salvo pasando por el ancestro común.

var body: some ReducerOf<Self> {
  Scope(state: \.perfil, action: \.perfil) { Perfil() }
  Reduce { state, action in
    switch action {
    case .perfil(.delegate(.cerroSesion)):
      state = State()          // el padre decide, el hijo solo informa
      return .none
    default:
      return .none
    }
  }
}

Esa asimetría es una decisión de diseño con ventajas y renuncias reales. Se gana trazabilidad, aislamiento y la posibilidad de montar cualquier subárbol por separado en una preview o en un test. Se pierde la facilidad para reaccionar en muchos sitios a un mismo evento global, que en Redux es el caso fácil y aquí exige un rodeo.

Ese rodeo tiene nombre desde el Nivel 25 y merece leerse a la luz de esta comparación: @Shared es la respuesta de TCA al único punto donde el rebanado jerárquico se queda corto frente al almacén global. La sesión del usuario, las preferencias o una lista de favoritos son estado que muchas features necesitan y que no tiene un ancestro común natural. Lo interesante es cómo se resuelve: no reintroduciendo un almacén global mutable, sino con una referencia con semántica de valor que se puede sobrescribir en un test y cuya persistencia es una estrategia declarada. Es decir, se recupera la comodidad del almacén global sin recuperar su defecto, que era que cualquiera podía escribir en él desde cualquier sitio sin dejar rastro.

Lo que la web tiene y TCA no

Conviene cerrar reconociendo las deudas en el otro sentido, porque son concretas. El depurador de Redux con viaje en el tiempo, inspección de cada acción y del diff de estado, y recarga con estado preservado, no tiene equivalente en el desarrollo para Apple: _printChanges ayuda, pero no es lo mismo. La serialización trivial del estado a texto, que en JavaScript es nativa y permite exportar una sesión entera desde la máquina de un usuario y reproducirla, en Swift exige que todo el árbol sea codificable, cosa que rara vez ocurre. Y Elm tiene algo que TCA no tendrá nunca: un compilador que no permite excepciones en tiempo de ejecución y cuyos mensajes de error son ejemplares, justo donde TCA es más débil, porque una expansión de macro dentro de un constructor de resultados genérico produce diagnósticos ilegibles.

🧬

Herencia confesa

El ciclo viene de Elm y el vocabulario de Redux. La aportación está en el traslado, no en la idea.

🔒

Inmutabilidad regalada

Lo que en Redux es la disciplina más cara de sostener, en Swift es el comportamiento por defecto.

🎯

Rutas de casos

Apuntar a un caso de un enum como si fuera una propiedad es lo que hace posible toda la navegación como dato.

🕰️

La deuda pendiente

Viaje en el tiempo, inspección de acciones y recarga con estado: ahí la web sigue muy por delante.

flowchart TD
E[Arquitectura de Elm en 2014] --> R[Redux en 2015]
E --> T[TCA en 2020]
R -- vocabulario --> T
E -- efecto como valor devuelto --> T
S[Sistema de tipos de Swift] --> T
S --> V[Semantica de valor obligatoria]
S --> A[Agotamiento con cargas utiles]
S --> C[Rutas de casos]
S --> K[Cancelacion tipada]
style T fill:#89b4fa,color:#11111b
Portar una arquitectura entre lenguajes es el experimento que revela qué parte era esencial y qué parte era andamio

La lección más valiosa de esta comparación no es cuál de las tres implementaciones es superior, sino lo que se aprende al ver la misma idea encarnada tres veces en materiales distintos. Cuando una arquitectura sobrevive a un cambio de lenguaje, lo que sobrevive es su núcleo y lo que se cae era andamio del lenguaje anterior. De Elm a Redux a TCA sobrevive exactamente esto: que el estado sea un valor único e inspeccionable, que los eventos sean datos enumerables, que la lógica sea una función total de estado y evento, y que el efecto sea algo que esa función describe en vez de algo que ejecuta. Todo lo demás resultó ser accidente. Se cayeron las suscripciones como concepto separado, porque la concurrencia estructurada las convierte en un efecto más. Se cayó el middleware entero, que en Redux era una pieza central y en TCA sencillamente no hace falta porque el efecto ya es un valor. Se cayó la combinación horizontal de reducers en favor del rebanado, porque un sistema de tipos con rutas permite algo que JavaScript no puede expresar. Y apareció una pieza nueva que en los otros dos no existía ni podía existir, la ruta de caso, sin la cual la navegación como dato sería impracticable. Ese inventario tiene una consecuencia práctica que va mucho más allá de Swift: te dice qué llevarte cuando cambies de plataforma. Si mañana escribes Kotlin con Compose, TypeScript con signals o Rust con una interfaz inmediata, las cuatro propiedades del núcleo son trasladables y valen la pena en cualquiera de los tres; el resto hay que reinventarlo con los materiales de allí, y copiar la forma de TCA sin sus rutas de casos y sin su semántica de valor produce una imitación que paga toda la ceremonia sin cobrar ninguna garantía. Aprender TCA bien es, por eso, mucho más rentable que aprender TCA: es aprender a distinguir, en cualquier arquitectura que te encuentres, cuál de sus reglas está sostenida por el compilador y cuál solo por la costumbre.

⚔️ Traduce una porción de estado desde la web
  1. Toma una porción de Redux real, propia o de un proyecto abierto, con al menos tres acciones y un efecto asíncrono.
  2. Traduce su estado a una struct y sus acciones a un enum con valores asociados. Anota cuántos campos podían estar ausentes o mal formados y ahora no pueden.
  3. Traduce el thunk o la saga a un Effect devuelto. Anota qué parte del middleware desapareció por completo.
  4. Busca en el original alguna reacción que dependa de que todas las porciones vean todas las acciones. Redíseñala con una acción de delegación hacia el padre y decide si el resultado es mejor o peor.
  5. Escribe una lista de tres cosas que perdiste al traducir. Si no encuentras ninguna, no has traducido con honestidad.