Convivir con MVVM: el punto de contacto entre dos mundos
Durante meses tu app tendrá pantallas con `Store` y pantallas con `ViewModel`, y esa convivencia no es un accidente vergonzoso sino el estado normal de una migración sana. Esta lección construye los tres puentes que aparecen siempre —padre viejo con hijo en TCA, padre en TCA con hijo viejo, y estado mutable que ambos mundos quieren tocar— y establece la única regla que evita que la coexistencia degenere: cada dato tiene un dueño y solo uno. Incluye el modelado de una pantalla heredada como valor sembrado, la vida del `ViewModel` dentro de la vista y por qué el intento de sincronizar en las dos direcciones es la forma más rápida de arruinar la migración.
Coexistir es más difícil que migrar. Escribir una feature nueva en TCA es un problema resuelto desde el Nivel 5; hacerla convivir con una pantalla que guarda la verdad en un objeto de referencia con propiedades mutables, cuya identidad importa y cuyo ciclo de vida lo decide UIKit, es un problema de acoplamiento entre dos ontologías distintas. Una dice que el estado es un valor que solo cambia dentro de un reducer; la otra dice que el estado es un objeto que cualquiera con una referencia puede modificar. Las dos pueden funcionar; lo que no puede funcionar es que las dos tengan razón sobre el mismo dato al mismo tiempo. Esta lección trata del contrato que impide que eso ocurra.
- Construir el puente en el que un padre con
ViewModelpresenta un hijo escrito en TCA. - Construir el puente inverso, en el que un padre en TCA presenta una pantalla heredada sin ensuciar su
State. - Aplicar la regla del dueño único a los datos que ambos mundos leen, y elegir en cada caso quién manda.
- Detectar la sincronización bidireccional antes de que produzca divergencias imposibles de reproducir.
Dos ontologías del estado
Un ViewModel moderno anotado con @Observable y un Store de TCA se parecen mucho por fuera: los dos son tipos de referencia, los dos son observados por SwiftUI y los dos redibujan solo lo que cambió, porque desde el Nivel 6 sabes que ambos se apoyan en el mismo framework de observación. La diferencia no está en la mecánica sino en quién tiene derecho a escribir. En el ViewModel, cualquiera que sostenga la referencia puede asignar una propiedad, y esa apertura es justamente lo que lo hace cómodo y lo que hace imposible saber, ante un estado incorrecto, quién lo puso así. En el Store la escritura está reservada al reducer y toda mutación tiene una acción con nombre que la explica.
De ahí se sigue el corolario práctico de toda la lección: la convivencia a nivel de vista es trivial, porque nada impide que una jerarquía de SwiftUI contenga vistas de los dos tipos; la convivencia a nivel de dato es donde se decide si la migración prospera. Dicho de otro modo, el problema nunca es que dos pantallas usen arquitecturas distintas, sino que dos arquitecturas se crean con autoridad sobre la misma información.
En una app de tamaño real la coexistencia dura trimestres, no semanas, y hay partes que quizá no se migren nunca. Conviene por tanto tratar los puentes como código de primera clase, con nombre propio, ubicación acordada y tests, y no como andamios provisionales escritos con prisa. El andamio provisional que dura dos años es la definición estándar de deuda técnica.
Puente uno: padre heredado, hijo en TCA
Es el caso de la lección anterior y el que más veces vas a escribir. El padre conserva su ViewModel, crea el Store del hijo cuando lo necesita y recibe el resultado por un cierre. Lo único que hay que cuidar es el ciclo de vida: el Store debe nacer con la presentación y morir con ella, cosa que se consigue construyéndolo dentro de la vista puente y no en el ViewModel del padre.
struct DetallePuente: View {
@State private var store: StoreOf<Detalle>
let alCerrar: (Resultado) -> Void
init(pedido: Pedido, alCerrar: @escaping (Resultado) -> Void) {
_store = State(wrappedValue: Store(initialState: Detalle.State(pedido: pedido)) { Detalle() })
self.alCerrar = alCerrar
}
var body: some View {
DetalleView(store: store)
.onChange(of: store.resultado) { _, valor in
if let valor { alCerrar(valor) }
}
}
}
Guardar el Store en el ViewModel del padre parece más ordenado y es un error con consecuencias: el store sobrevive al cierre de la pantalla, sus efectos de larga vida siguen corriendo, y al volver a abrir aparece el estado de la vez anterior. La regla es que el dueño del store sea quien tenga el mismo tiempo de vida que la pantalla.
Puente dos: padre en TCA, pantalla heredada
Este es el que casi nadie modela bien a la primera. La tentación es meter el ViewModel dentro del State del padre, y no se puede: no es un valor, no es Equatable de forma útil y no es Sendable. La solución correcta es modelar en el State únicamente la semilla de la pantalla heredada —los datos con los que arranca— y dejar que el objeto de referencia nazca dentro de la vista, viva lo que dure la presentación y se comunique de vuelta por una acción.
@Reducer
struct Pedidos {
@ObservableState
struct State: Equatable {
@Presents var editorLegado: EditorSemilla?
}
@ObservableState
struct EditorSemilla: Equatable, Identifiable {
let id: UUID
var pedido: Pedido
}
enum Action {
case editarPulsado(Pedido)
case editorLegado(PresentationAction<EditorAccion>)
enum EditorAccion: Equatable { case guardo(Pedido), cancelo }
}
var body: some ReducerOf<Self> {
Reduce { estado, accion in ... }
.ifLet(\.$editorLegado, action: \.editorLegado)
}
}
El ifLet sin reducer hijo es la variante que existe precisamente para esto: gestiona el ciclo de vida y el cierre de un destino cuyo estado no pertenece a ninguna feature. La semilla es un valor sin comportamiento y por eso puede vivir en el State sin romper nada.
.sheet(item: $store.scope(state: \.editorLegado, action: \.editorLegado)) { semillaStore in
EditorLegadoVista(
pedido: semillaStore.pedido,
alGuardar: { store.send(.editorLegado(.presented(.guardo($0)))) },
alCancelar: { store.send(.editorLegado(.presented(.cancelo))) }
)
}
Repara en lo que se ha ganado. La navegación sigue siendo un dato del padre, con todo lo que eso implicaba en el Nivel 17: se puede abrir el editor desde un enlace profundo, se puede afirmar en un test que se abrió y se puede restaurar una sesión, aunque la pantalla que hay dentro sea código de 2019 que nadie ha tocado. El puente encapsula la parte imperativa en una caja del tamaño exacto de una pantalla y el resto del sistema no se contamina.
La regla del dueño único
Queda el caso difícil: un dato que ambos mundos leen y que ambos querrían escribir. La sesión del usuario, el carrito, el modo oscuro, la lista descargada que dos pantallas muestran. Aquí la única política sostenible es que el dato tenga exactamente un dueño y que el otro lado lo consulte a través de una interfaz de lectura, o le pida el cambio al dueño. Sincronizar en las dos direcciones parece razonable durante una tarde y produce bucles, escrituras que se pisan y estados que solo aparecen en el dispositivo de un compañero.
| Situación | Dueño recomendado | Cómo lo ve el otro lado |
|---|---|---|
| Sesión y autenticación | El mundo heredado, hasta el final | Dependencia de solo lectura más un flujo de cambios |
| Datos de una pantalla ya migrada | El Store de la feature |
Cierre de salida hacia el padre heredado |
| Preferencias y ajustes | TCA con @Shared |
Adaptador que lee el mismo almacén |
| Caché de red compartida | La capa de datos existente | Dependencia envuelta, nunca copiada al State |
No falla el día que se escribe. Falla semanas después, con un informe que dice que el carrito muestra dos artículos y el resumen tres, que ocurre una vez de cada veinte y que nadie reproduce. Cuando veas un observador que copia de A hacia B y otro que copia de B hacia A, no busques la condición de carrera concreta: borra uno de los dos y decide quién manda.
La sesión merece un comentario aparte porque es el dato que todo el mundo intenta migrar primero y el que hay que migrar el último. Está tocado por cada pantalla, decide el arranque de la app y suele vivir en un singleton con oyentes registrados desde media docena de sitios. Mientras quede una sola pantalla heredada que lo escriba, el dueño tiene que seguir siendo el mundo viejo, y TCA lo consume como una dependencia que expone el valor actual y un flujo de cambios. Invertir esa titularidad es el último paso de la migración, no el primero.
Un dato, un dueño
El otro lado lee o pide. Nunca escribe. La sincronización bidireccional no se arregla, se prohíbe.
Semilla, no objeto
En el State va el valor con el que arranca la pantalla heredada, jamás el ViewModel.
El store vive lo que la pantalla
Construirlo en la vista puente. Guardarlo en el padre resucita estado y efectos viejos.
La sesión, la última
Es el dato más tocado de la app. Se migra cuando ya no queda nadie fuera que lo escriba.
flowchart LR VM[ViewModel heredado] -- semilla --> ST[Store de la feature] ST -- hecho consumado --> VM SG[Singleton de sesion] -- dependencia de lectura --> ST ST -- peticion de cambio --> SG X[Escritura directa desde ambos lados] -.prohibido.-> SG style ST fill:#89b4fa,color:#11111b style X fill:#f38ba8,color:#11111b
Quien mira una migración desde fuera ve dos estilos de escribir pantallas y concluye que el problema es de gusto, o como mucho de consistencia, y que se resolverá cuando todo esté en el mismo estilo. Es un diagnóstico superficial y explica por qué tantas migraciones producen apps que fallan más durante la transición que antes de empezarla. El problema real es que una arquitectura no es un estilo sino una asignación de responsabilidades sobre la verdad, y dos asignaciones simultáneas sobre el mismo dato no se suman: se contradicen. Un Store que sostiene una copia de la sesión y un singleton que sostiene la sesión no son redundancia inofensiva, son dos relojes que se irán separando, y toda la teoría de sistemas distribuidos que se aplica a dos nodos de una red se aplica exactamente igual a dos objetos dentro de un proceso, con la diferencia de que aquí nadie ha escrito un protocolo de consenso ni piensa hacerlo. Por eso la regla del dueño único no es una convención de equipo sino la única que reduce el problema a algo tratable: si solo una parte puede escribir, no hay conflicto que resolver; si la otra necesita cambiar el dato, lo hace a través del dueño y la operación queda registrada en un solo sitio. Hay además una consecuencia que conviene ver a tiempo. La migración, entendida así, deja de ser una tarea de traducción de código y se convierte en una secuencia de transferencias de titularidad: cada paso consiste en decidir que a partir de mañana este dato lo custodia el otro mundo, y todo lo demás —reescribir la pantalla, cambiar el modelo, ajustar las llamadas— es consecuencia mecánica de esa decisión. Ordenar la migración es, por tanto, ordenar las transferencias, y ahí aparece por qué los datos más compartidos van al final: transferir la titularidad de algo que veinte sitios escriben exige haber convertido antes esos veinte sitios en lectores. Quien empieza por la sesión no está siendo ambicioso, está intentando cerrar el último paso el primer día.
- Enumera los datos que más de una pantalla lee: sesión, carrito, ajustes, cachés, banderas de configuración. Suelen ser entre cinco y quince.
- Para cada uno, busca todos los sitios que lo escriben. Si hay más de uno, ya tienes un candidato a divergencia aunque hoy no falle.
- Escribe al lado de cada dato quién debe ser su dueño durante la convivencia y qué interfaz usará el otro lado. Comprométete por escrito con esa asignación.
- Toma una pantalla heredada que presente otra ya migrada y comprueba dónde vive el
Store. Si vive en elViewModeldel padre, muévelo a la vista puente y verifica que al reabrir la pantalla el estado arranca limpio. - Modela una pantalla heredada como destino de un padre en TCA usando una semilla
Equatable. Escribe un test que abra ese destino y afirme el resultado, sin instanciar elViewModelheredado.