Previews y apps de demo por módulo: arrancar una feature aislada
Una feature que no puede arrancar sola no es un módulo, es un fragmento. Esta lección construye las dos formas de arrancarla: la preview, que es literalmente un `Store` con dependencias declaradas en el sitio, y la app de demo, un objetivo ejecutable de veinte líneas que importa una única feature y la lleva a un dispositivo real. Explica por qué el store no debe reconstruirse en cada redibujado, cómo escribir una preview por cada rama interesante del dominio en vez de una sola con datos felices, y qué dependencias hay que fijar —reloj, identificadores, fecha— para que el canvas sea determinista y no una lotería.
El criterio más honesto para saber si una feature está de verdad aislada no es leer su manifiesto sino intentar arrancarla sola. Si para ver una pantalla hace falta autenticarse, migrar una base de datos y esperar a tres peticiones de red, el módulo existe en el papel pero no en la práctica, y la modularización te está cobrando el precio sin darte el premio. Las previews y las apps de demo son la prueba de vida de ese aislamiento: no son una herramienta de diseño ni un lujo, son el experimento que verifica la hipótesis de las tres lecciones anteriores. Y como todo experimento, solo vale si es reproducible, que es la razón por la que una parte grande de esta lección trata sobre determinismo.
- Construir una preview como un
Storecon sus dependencias sobrescritas en el sitio, y evitar el error de reconstruirlo en cada redibujado. - Escribir una preview por cada rama interesante del estado —vacío, cargando, error, lleno— en lugar de una sola con el camino feliz.
- Declarar un objetivo ejecutable de demo por feature en el manifiesto y entender qué cubre que la preview no cubre.
- Fijar reloj, generador de identificadores y fecha para que el arranque aislado sea determinista y reproducible.
La preview es un store con dependencias declaradas
En TCA una preview no tiene nada especial: se construye el mismo Store que usaría la app, con el estado inicial que te interese, y se sobrescriben las dependencias en el punto de construcción. El bloque withDependencies es el mismo mecanismo que ya conoces de los tests, aplicado al canvas.
import ComposableArchitecture
import PerfilFeature
import SwiftUI
#Preview("Perfil cargado") {
NavigationStack {
PerfilView(
store: Store(initialState: Perfil.State(usuario: .muestra)) {
Perfil()
} withDependencies: {
$0.apiClient.perfil = { _ in .muestra }
$0.continuousClock = ImmediateClock()
}
)
}
}
Hay un detalle que muerde a todo el mundo la primera vez. El cuerpo de una preview se reevalúa cuando el canvas redibuja, y si el Store se construye ahí dentro sin más, cada redibujado fabrica un store nuevo con el estado inicial, de modo que la pantalla se reinicia sola y parece que nada funciona. La solución es hacer que el store sobreviva a las reevaluaciones, con una propiedad estática o con estado de vista previsualizable.
private enum Demo {
static let store = Store(initialState: Perfil.State()) { Perfil() }
}
#Preview("Interactiva") {
PerfilView(store: Demo.store)
}
El síntoma es inconfundible: escribes en un campo, la letra aparece y desaparece; abres una hoja modal y se cierra al instante. No es un fallo de TCA ni del reducer, es que el store se está reconstruyendo. La misma trampa aparece cuando la vista padre crea el store en su body en lugar de recibirlo, y ahí sí llega hasta la app publicada, donde se manifiesta como estado que se pierde al rotar el dispositivo.
Una preview por rama, no una preview feliz
La preview que casi todo el mundo escribe muestra datos correctos y una pantalla completa, que es exactamente el estado que menos problemas da. El valor real aparece cuando cada rama del dominio tiene la suya, porque entonces el canvas se convierte en una galería de estados y revisar una feature entera cuesta un vistazo.
#Preview("Vacio") {
PerfilView(store: Store(initialState: Perfil.State()) { Perfil() })
}
#Preview("Cargando") {
PerfilView(
store: Store(initialState: Perfil.State(cargando: true)) { Perfil() }
withDependencies: { $0.apiClient.perfil = { _ in try await Task.never() } }
)
}
#Preview("Error de red") {
PerfilView(
store: Store(initialState: Perfil.State()) { Perfil() }
withDependencies: { $0.apiClient.perfil = { _ in throw ErrorRed.sinConexion } }
)
}
Fíjate en la técnica del estado de carga: en lugar de inventar un campo artificial, se inyecta un cliente cuya petición nunca termina, y así el estado de carga se produce por el mismo camino que en producción. Es la diferencia entre dibujar un estado y provocarlo. Y como el modelado del dominio de los niveles anteriores hace imposibles los estados inválidos, la lista de previews tiende a coincidir con la lista de casos del enum que representa la carga, lo que da un criterio objetivo para saber cuándo has terminado.
La app de demo: un ejecutable por feature
La preview tiene un techo. No ejecuta el ciclo de vida completo de una aplicación, no permite probar en un dispositivo con condiciones reales de red, no soporta bien la navegación profunda ni las notificaciones, y no se le puede pasar el perfilador con comodidad. Para todo eso está la app de demo: un objetivo ejecutable que importa una única feature y la arranca con dependencias de prueba.
.executableTarget(name: "PerfilDemo", dependencies: ["PerfilFeature"])
import ComposableArchitecture
import PerfilFeature
import SwiftUI
@main
struct PerfilDemoApp: App {
static let store = Store(initialState: Perfil.State()) {
Perfil()
} withDependencies: {
$0.apiClient = .previewValue
$0.continuousClock = ImmediateClock()
$0.uuid = .incrementing
}
var body: some Scene {
WindowGroup { NavigationStack { PerfilView(store: Self.store) } }
}
}
Veinte líneas compran mucho: un artefacto instalable que diseño y control de calidad pueden usar sin credenciales, un entorno donde perfilar una sola feature sin el ruido del resto de la app, y una prueba periódica y automática de que el aislamiento sigue siendo real, porque el día que alguien introduzca una dependencia indebida la demo dejará de compilar antes que nada.
Preview para la forma
Iterar sobre disposición, tipografía y estados visuales. Ciclo de segundos, sin dispositivo.
Demo para el comportamiento
Ciclo de vida completo, navegación, dispositivo real y perfilador sobre una sola feature.
Cero dependencias vivas
Ni preview ni demo importan la capa real. Si la necesitan, el aislamiento estaba roto.
Compila luego existe
La demo es un test de arquitectura que corre en cada build sin que nadie lo escriba.
Determinismo: reloj, identificadores y fecha
Un arranque aislado que depende del reloj del sistema o de identificadores aleatorios produce pantallas distintas cada vez, y una pantalla que cambia sola no sirve ni para revisar un diseño ni para reproducir un fallo. Las tres dependencias que hay que fijar siempre son el reloj, el generador de identificadores y la fecha, y las tres tienen una versión controlada lista para usar.
| Dependencia | Valor para arrancar aislado | Qué elimina |
|---|---|---|
continuousClock |
ImmediateClock |
Esperas reales en animaciones y rebotes |
uuid |
.incrementing |
Identificadores distintos en cada arranque |
date |
.constant con una fecha fija |
Textos relativos que cambian solos |
En el objetivo de la app real, en cambio, no se sobrescribe nada: es el único sitio donde se inyectan las implementaciones vivas, y conviene hacerlo una sola vez al arrancar en lugar de repartirlo por la jerarquía.
flowchart TD F[PerfilFeature] --> PV[Preview con dependencias de prueba] F --> DM[PerfilDemo ejecutable] F --> TS[PerfilFeatureTests] F --> AP[AppFeature] AP --> LV[Implementaciones vivas] PV --> D[Reloj inmediato uuid incremental fecha fija] DM --> D style D fill:#a6e3a1,color:#11111b style LV fill:#fab387,color:#11111b
Se puede tener un manifiesto impecable y una arquitectura que no existe. Basta con que la feature, aunque no importe a sus hermanas, presuponga silenciosamente un mundo entero: un usuario ya autenticado, una caché ya poblada, un identificador que solo el objetivo raíz sabe fabricar. Nada de eso aparece en el grafo de dependencias, porque no son importaciones sino suposiciones, y las suposiciones no se compilan. El experimento de arrancar la feature sola es el que las hace visibles todas de golpe, porque una suposición no satisfecha se manifiesta inmediatamente como una pantalla en blanco, un fallo o un valor de prueba que se niega a funcionar. Por eso la preview y la app de demo no son herramientas de presentación sino instrumentos de verificación, y por eso conviene tratarlas con el mismo rigor que a los tests: si dejan de compilar, se arreglan; si dejan de arrancar, se investiga. Hay además una lectura más profunda, que enlaza con todo lo que llevas construido. Una feature capaz de arrancar sola con dependencias de prueba es exactamente lo mismo que una feature testeable, porque el TestStore y la preview usan el mismo mecanismo de inyección y exigen las mismas condiciones. No son dos disciplinas que se refuerzan por casualidad: son la misma propiedad —que la feature sea una función de sus dependencias explícitas y de nada más— observada desde dos ángulos. Quien tiene previews ricas por cada rama del dominio ya ha hecho, sin llamarlo así, el trabajo de diseño que hace que los tests sean fáciles de escribir; y quien no logra arrancar una feature en el canvas tampoco logrará testearla, por mucho que el manifiesto diga que está aislada.
- Elige una feature y escribe una preview mínima. Anota cada cosa que necesitas inventar para que compile: cada una es una suposición que el módulo hacía en silencio.
- Comprueba si la pantalla se reinicia al interactuar. Si ocurre, mueve el store a una propiedad estática y verifica que el estado ya persiste entre redibujados.
- Escribe una preview por cada caso del
enumque representa la carga, y provoca el estado de carga con un cliente que nunca responde en vez de con un campo artificial. - Añade al manifiesto un objetivo ejecutable de demo que solo dependa de esa feature. Instálalo en un dispositivo y navega por él sin credenciales.
- Fija reloj, identificadores y fecha, cierra y vuelve a abrir la demo tres veces y comprueba que ves exactamente la misma pantalla. Si no, queda una fuente de no determinismo sin declarar.