TCA frente a `MVVM` con `@Observable`: la comparación honesta
La comparación que casi nunca se hace bien, porque enfrenta la ceremonia medible de una librería con la ceremonia imaginaria de un patrón que cada equipo implementa a su manera. Esta lección pone a los dos enfoques a resolver el mismo caso real —un buscador con rebote, cancelación y fallo—, cuenta las declaraciones de cada uno sin trampas, y después mide lo único que justifica la diferencia: qué puede afirmar un test en cada modelo. Termina tratando la curva de aprendizaje como lo que es, una inversión con calendario de amortización, y no como un rasgo de carácter de quien la paga o de quien la rehúsa.
La comparación entre TCA y MVVM está viciada desde la primera frase porque los dos términos no son de la misma especie. TCA es una librería concreta, con un runtime, una versión y un comportamiento que se puede ejecutar; MVVM es un nombre para una costumbre, y la costumbre de cada equipo es distinta. Comparar una implementación con una convención produce siempre el mismo espejismo: la implementación enseña todos sus costes porque están escritos, y la convención esconde los suyos porque están repartidos entre la cabeza de cada persona y las decisiones que nadie anotó. Esta lección intenta corregir ese sesgo del único modo que funciona, que es fijar un caso concreto, escribir las dos versiones completas y contar sobre el código y no sobre el recuerdo.
- Situar qué es hoy
MVVMcon el framework de observación y en qué se parece más aTCAde lo que se admite. - Contar la ceremonia real de ambos enfoques sobre una misma feature con asincronía, cancelación y fallo.
- Distinguir afirmar estados alcanzables de afirmar transiciones, que es la diferencia de fondo en testabilidad.
- Tratar la curva de aprendizaje con un calendario de amortización en vez de con adjetivos.
Dos respuestas al mismo problema, con distinto alcance
El MVVM de 2026 no es el de hace diez años. El framework de observación eliminó el ritual de ObservableObject y @Published, hizo la observación granular por propiedad y dejó una forma muy limpia: una clase anotada con @Observable que guarda estado mutable, expone métodos que lo mutan y usa async dentro de ellos. Ese modelo es bueno, y conviene decirlo sin condescendencia: resuelve bien la mayoría de las pantallas que la mayoría de la gente escribe. La diferencia de fondo no es estética. MVVM es un patrón sin árbitro: nada en el compilador ni en el runtime impide que dos objetos muten el mismo estado, que un método llame a otro método que muta a la mitad de una operación asíncrona, o que la vista escriba directamente en el modelo. TCA convierte esas mismas prohibiciones en propiedades del tipo: el estado solo es escribible desde dentro del reducer, los eventos son casos de un enum que el compilador obliga a agotar, y el trabajo asíncrono es un valor devuelto en lugar de una llamada suelta. De ahí una consecuencia que suele pesar más que cualquier argumento técnico: la varianza. Dos bases de código de MVVM escritas por equipos distintos pueden no parecerse en nada; dos de TCA se parecen tanto que alguien puede cambiar de proyecto y leer una feature el primer día. Lo que se compra no es solo una arquitectura sino la ausencia de discusión sobre ella.
Ceremonia: el mismo buscador, contado en declaraciones
El caso es el mínimo honesto: un campo de búsqueda con rebote de trescientos milisegundos, cancelación de la petición anterior y manejo del fallo. Cualquier ejemplo más pequeño favorece artificialmente a MVVM, porque la ventaja de TCA aparece exactamente donde hay asincronía que coordinar. Y las dos versiones inyectan el reloj y la red: comparar una implementación rigurosa con otra que no lo es no mide arquitecturas, mide rigor.
@Observable
final class BuscadorModel {
var consulta = "" { didSet { programar() } }
var resultados: [Item] = []
var cargando = false
private var tarea: Task<Void, Never>?
private let api: API
private let reloj: any Clock<Duration>
init(api: API, reloj: any Clock<Duration>) { self.api = api; self.reloj = reloj }
private func programar() {
tarea?.cancel()
cargando = true
tarea = Task { [consulta] in
try? await reloj.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
resultados = (try? await api.buscar(consulta)) ?? []
cargando = false
}
}
}
@Reducer
struct Buscador {
@ObservableState
struct State: Equatable {
var consulta = ""
var resultados: [Item] = []
var cargando = false
}
enum Action {
case consultaCambiada(String)
case respuesta(Result<[Item], any Error>)
}
@Dependency(\.api) var api
@Dependency(\.continuousClock) var reloj
private enum ID { case busqueda }
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .consultaCambiada(texto):
state.consulta = texto
state.cargando = true
return .run { send in
try await reloj.sleep(for: .milliseconds(300))
await send(.respuesta(Result { try await api.buscar(texto) }))
}
.cancellable(id: ID.busqueda, cancelInFlight: true)
case let .respuesta(resultado):
state.cargando = false
state.resultados = (try? resultado.get()) ?? []
return .none
}
}
}
}
La versión de TCA es más larga, y negarlo sería deshonesto. Lo interesante es de qué está hecho el exceso: un caso de enum por evento, una rama por caso, un identificador para la cancelación y dos declaraciones de dependencia. Nada de eso es lógica; es nomenclatura obligatoria. Y a cambio desaparecen tres cosas que en la versión imperativa hay que sostener a mano: la propiedad que guarda la tarea, la comprobación de cancelación, y el orden implícito en que didSet dispara trabajo mientras el usuario sigue escribiendo.
| Eje de ceremonia | MVVM con @Observable |
TCA |
|---|---|---|
| Nombrar un evento nuevo | nada: se añade un método | un caso de enum y una rama |
| Cancelar el trabajo anterior | guardar y cancelar la tarea a mano | un identificador y cancelInFlight |
| Declarar una costura testeable | convención del equipo | @Dependency y su clave |
| Encontrar quién muta un campo | buscar por todo el módulo | está en el reducer, siempre |
Testabilidad: afirmar estados frente a afirmar transiciones
Aquí la diferencia deja de ser cuantitativa. Un test del modelo observable instancia la clase, llama al método, espera y comprueba el estado final. Puede hacerse bien, y con el reloj y la red inyectados es un buen test. Pero hay algo que estructuralmente no puede hacer: afirmar la secuencia. Que cargando pasó a verdadero antes de la petición y volvió a falso después es un hecho que ocurre entre dos instantes que el test no controla, y que solo se observa muestreando. Un test así verifica destinos, no caminos.
@Test func rebotarYResponder() async {
let reloj = TestClock()
let store = TestStore(initialState: Buscador.State()) {
Buscador()
} withDependencies: {
$0.continuousClock = reloj
$0.api.buscar = { _ in [Item(id: 1, nombre: "flan")] }
}
await store.send(.consultaCambiada("fl")) {
$0.consulta = "fl"
$0.cargando = true
}
await reloj.advance(by: .milliseconds(300))
await store.receive(\.respuesta) {
$0.cargando = false
$0.resultados = [Item(id: 1, nombre: "flan")]
}
}
El TestStore afirma cada transición en orden y falla si el estado cambió de una forma no declarada o si quedó un efecto vivo al terminar. Ese segundo criterio es el que casi nadie tiene en MVVM: una tarea huérfana que sigue corriendo después de que la pantalla desapareció no rompe ningún test, porque no hay nadie a quien le importe. La exhaustividad convierte el olvido en fallo, y esa es toda la tesis.
La curva: qué se aprende y cuándo se amortiza
La curva de TCA es real, empinada y está mal repartida: casi todo el coste se paga en las primeras semanas. Un ingeniero competente en Swift necesita del orden de dos a cuatro semanas para escribir features solo, y dos o tres meses para manejar con soltura la composición, las rutas de casos y las dependencias. Los errores de compilación son, con diferencia, la peor parte de la experiencia, porque una expansión de macro con constructores de resultados genéricos produce mensajes que no señalan la línea culpable. Frente a eso, la curva de MVVM es casi plana el primer día y no termina nunca, porque lo que se aprende no es un framework sino las convenciones de ese equipo concreto, que no están documentadas y cambian. El coste existe pero está disperso, y por eso no se percibe: se paga en revisiones de código donde se discute dónde va la lógica, y en sesiones de depuración donde la pregunta es quién mutó esto.
Patrón contra mecanismo
MVVM es una convención sin árbitro; TCA convierte esas mismas reglas en errores de compilación.
La ceremonia es nomenclatura
Casi todo el exceso de líneas es nombrar eventos y costuras, no lógica adicional.
Caminos, no destinos
Un test de modelo afirma el estado final; el TestStore afirma cada transición y el silencio final.
Coste anticipado
La curva se paga entera al principio y una vez por persona; la varianza de un patrón se paga siempre.
flowchart LR subgraph MVVM V1[Vista] --> M1[Metodo del modelo] M1 --> P1[Mutacion directa y tarea suelta] P1 --> V1 end subgraph TCA V2[Vista] --> A2[Accion] A2 --> R2[Reducer puro] R2 --> S2[Estado nuevo] S2 --> V2 R2 --> E2[Efecto como valor] E2 --> A2 end style P1 fill:#f38ba8,color:#11111b style R2 fill:#a6e3a1,color:#11111b
Todo el debate entre estos dos enfoques se aclara cuando se acepta que ambos persiguen las mismas propiedades y solo discrepan en quién las garantiza. Un MVVM excelente y un TCA correcto acaban cumpliendo la misma lista: el estado tiene un dueño, las mutaciones son rastreables, los efectos son cancelables, las dependencias son sustituibles y la lógica es testeable sin simulador. La diferencia está en el mecanismo de cumplimiento. En MVVM esa lista es una intención que cada persona sostiene con atención, revisión y memoria; funciona espléndidamente en equipos pequeños con contexto compartido y alta disciplina, y se erosiona de forma predecible cuando cambian las personas, cuando hay prisa o cuando la app crece más allá de lo que una cabeza abarca. La erosión no es un fallo moral: es la forma normal de comportarse de cualquier regla sin árbitro. TCA toma exactamente esa lista y la traslada al sistema de tipos, con lo que deja de depender de la atención de nadie. Ese traslado no es gratis, y su precio se llama ceremonia: hay que nombrar cada evento, declarar cada costura, agotar cada rama. La pregunta útil, entonces, no es cuál arquitectura es mejor en abstracto, sino qué probabilidad tiene tu equipo concreto de sostener la disciplina durante los años que va a vivir ese código, y cuánto cuesta un fallo cuando la disciplina cede. Si esa probabilidad es alta y el coste de un fallo es bajo, la ceremonia es un impuesto sin contrapartida y MVVM con @Observable es la respuesta correcta. Si la probabilidad baja con el tiempo —porque el equipo crece, porque rota, porque el dominio se complica— y un estado corrupto significa un cobro duplicado o un mensaje perdido, entonces pagar por adelantado una garantía que no depende de nadie es la mejor compra disponible. Lo que no existe es la opción de tener las garantías sin pagarlas: o las impone el compilador, o las impone la vigilancia humana, y esta segunda cobra intereses.
- Elige una pantalla tuya con asincronía real: rebote, cancelación, fallo y al menos dos fuentes de datos. Nada de contadores.
- Escríbela con
@Observableinyectando el reloj y la red, y después conTCA. Exige el mismo rigor a las dos versiones. - Cuenta líneas, pero sobre todo cuenta declaraciones que son lógica frente a declaraciones que son nomenclatura. Anota la proporción.
- Escribe para cada versión un test que afirme que la carga se activa antes de la petición y se apaga después. Anota cuál de los dos pudiste escribir sin muestrear.
- Cronometra cuánto tardas en añadir un filtro nuevo a cada versión. Ese número, y no el de líneas, es el que predice tus próximos tres años.