Una preview de SwiftUI debería permitirnos cambiar una vista y comprobar el resultado casi de inmediato. Sin embargo, esa ventaja desaparece cuando la previsualización necesita iniciar la aplicación, abrir una base de datos o esperar la respuesta de un servidor antes de mostrar contenido. La preview deja de ser una herramienta de diseño rápida y se convierte en otra ejecución frágil de la aplicación.
El problema no está en #Preview. Xcode compila y ejecuta el código que le entregamos, incluida cualquier tarea asíncrona iniciada por la vista. Si esa ruta alcanza el servicio de producción, la previsualización hereda su latencia, sus errores, sus credenciales y su dependencia de la conexión. La solución consiste en separar la obtención de datos mediante un protocolo e inyectar una implementación en memoria con escenarios estáticos.
Una preview no debe depender del mundo exterior
Supongamos que una pantalla muestra las próximas salidas de transporte. Si el modelo crea directamente el cliente de red, no existe un punto desde el que sustituirlo:
@MainActor
@Observable
final class DeparturesModel {
private let service = NetworkDeparturesService()
var departures: [Departure] = []
func load() async throws {
departures = try await service.departures()
}
}
Este acoplamiento parece cómodo mientras solo hay una implementación. En una preview obliga a disponer de conexión de red, un servidor operativo y una sesión válida. También hace que el contenido cambie con la hora o con los datos del entorno, de modo que un estado vacío o un error resultan difíciles de reproducir.
Realizar la petición una vez y copiar su JSON en el proyecto reduce la dependencia de red, pero no resuelve necesariamente el coste. Leer un archivo, decodificar una respuesta grande o preparar un contenedor persistente continúa siendo trabajo que se repite cada vez que Xcode reinicia la preview. Para componentes pequeños suele ser más rápido expresar directamente los pocos valores que necesita la interfaz.
Extraer el servicio detrás de un protocolo
El primer paso es definir la capacidad que necesita el modelo, sin exponer detalles de URLSession, rutas HTTP ni autenticación:
struct Departure: Identifiable, Sendable {
let id: UUID
let line: String
let destination: String
let minutes: Int
}
protocol DeparturesService: Sendable {
func departures() async throws -> [Departure]
}
La implementación real conforma ese protocolo y conserva toda la infraestructura de producción. El modelo, en cambio, solo conoce el contrato:
@MainActor
@Observable
final class DeparturesModel {
enum State {
case loading
case loaded([Departure])
case failed
}
private let service: any DeparturesService
private(set) var state: State = .loading
init(service: any DeparturesService) {
self.service = service
}
func load() async {
state = .loading
do {
state = .loaded(try await service.departures())
} catch {
state = .failed
}
}
}
La inyección no es una particularidad de las previews. Expresa que la fuente de datos es una dependencia del modelo y permite utilizar la misma lógica con red, datos locales, pruebas unitarias o una implementación temporal. La preview se beneficia de esa decisión porque puede elegir una fuente inmediata y determinista.
Modelar escenarios con un mock en memoria
El mock no necesita imitar una capa HTTP completa. Solo debe cumplir el protocolo y devolver el resultado que la interfaz necesita representar:
struct PreviewDeparturesService: DeparturesService {
enum Scenario: Sendable {
case success([Departure])
case failure
}
let scenario: Scenario
func departures() async throws -> [Departure] {
switch scenario {
case let .success(departures):
departures
case .failure:
throw URLError(.badServerResponse)
}
}
}
Podríamos construir PreviewDeparturesService manualmente en cada bloque #Preview, pero las factorías estáticas hacen que los casos relevantes tengan nombre y puedan reutilizarse. Una extensión restringida del protocolo mantiene además la sintaxis de inyección compacta:
extension DeparturesService where Self == PreviewDeparturesService {
static var previewWithDepartures: Self {
PreviewDeparturesService(
scenario: .success([
Departure(
id: UUID(),
line: "C3",
destination: "Puerto",
minutes: 2
),
Departure(
id: UUID(),
line: "M1",
destination: "Museo de Ciencias",
minutes: 11
)
])
)
}
static var previewEmpty: Self {
PreviewDeparturesService(scenario: .success([]))
}
static var previewFailure: Self {
PreviewDeparturesService(scenario: .failure)
}
}
Gracias a la restricción Self == PreviewDeparturesService, Swift puede resolver expresiones como .previewEmpty cuando el inicializador espera any DeparturesService. El nombre describe la intención de la preview y oculta los detalles necesarios para preparar sus datos.
Estas propiedades son calculadas, por lo que crean un servicio nuevo en cada acceso. Es una diferencia útil frente a compartir una única instancia mutable entre previews: cada escenario comienza limpio y no arrastra estado de una ejecución anterior de la previsualización. Si la implementación es un valor inmutable, también podrían utilizarse constantes estáticas; lo importante es que no exista estado global que vuelva a introducir resultados impredecibles.
Una preview para cada estado visible
La vista puede iniciar la carga igual que en la aplicación. Lo único que cambia es el servicio que recibe su modelo:
struct DeparturesView: View {
let model: DeparturesModel
var body: some View {
Group {
switch model.state {
case .loading:
ProgressView("Consultando salidas")
case let .loaded(departures) where departures.isEmpty:
ContentUnavailableView(
"Sin salidas próximas",
systemImage: "tram"
)
case let .loaded(departures):
List(departures) { departure in
LabeledContent(
departure.destination,
value: "\(departure.minutes) min"
)
}
case .failed:
ContentUnavailableView(
"No se pudieron cargar las salidas",
systemImage: "wifi.exclamationmark"
)
}
}
.task {
await model.load()
}
}
}
Cada bloque crea su propio modelo y el mock responde sin operaciones de entrada/salida. @Previewable permite que @State viva dentro de la propia preview, de modo que el modelo observable mantenga su identidad mientras interactuamos con la previsualización:
#Preview("Con salidas") {
@Previewable @State var model = DeparturesModel(
service: .previewWithDepartures
)
DeparturesView(model: model)
}
#Preview("Sin salidas") {
@Previewable @State var model = DeparturesModel(
service: .previewEmpty
)
DeparturesView(model: model)
}
#Preview("Error") {
@Previewable @State var model = DeparturesModel(
service: .previewFailure
)
DeparturesView(model: model)
}
Los tres casos recorren el mismo método load() que utiliza la aplicación. No se asigna manualmente el estado interno del modelo ni se añade una rama especial que compruebe si el proceso se está ejecutando dentro de una preview. La diferencia queda en el límite correcto: la dependencia que proporciona los datos.
También conviene representar la carga de forma deliberada. Un mock puede incluir una suspensión controlada para inspeccionar la transición o el modelo puede aceptar un estado inicial destinado a catálogos visuales. No es recomendable dejar una operación bloqueada indefinidamente, porque las tareas de previews se cancelan y reinician con frecuencia mientras editamos el archivo.
Datos estáticos no significa datos irreales
Un mock rápido pierde valor si solo contiene el happy path más sencillo. Los escenarios deben representar las condiciones que pueden romper el diseño: una lista vacía, un error, textos largos, valores límite, contenido localizado o varias filas con longitudes distintas.
Tampoco hace falta copiar miles de registros de producción. Una preview de una celda necesita los datos suficientes para ejercer esa celda; una pantalla completa puede requerir una colección pequeña pero variada. Reducir el conjunto acelera la preparación y deja claro qué comportamiento pretende documentar cada escenario.
Cuando la fidelidad exige una dependencia compleja, los datos en memoria siguen siendo preferibles a la infraestructura real. Apple utiliza este enfoque en sus ejemplos de SwiftData: crea un ModelContainer configurado para almacenamiento en memoria y lo inyecta en las previews. Para configuraciones compartidas, PreviewModifier permite preparar una vez dependencias como contenedores de modelos y aplicarlas mediante un PreviewTrait.
Qué mejora y qué no mejora este patrón
Un servicio mockeado elimina peticiones de red, credenciales, disponibilidad del backend y decodificaciones innecesarias del recorrido crítico de la preview. El resultado suele ser una aparición más rápida y, sobre todo, repetible. También permite trabajar sin conexión y revisar estados de error sin manipular el servidor.
La misma distinción se aplica a una vista que sigue realizando trabajo pesado en body, decodifica imágenes grandes o consulta la persistencia de forma síncrona. El mock ayuda a aislar el problema: si la preview continúa siendo lenta con una respuesta inmediata, la causa está más cerca de la construcción o el renderizado de la interfaz.
Mantener los mocks fuera del producto
Los tipos de preview pueden vivir en un grupo dedicado, en un paquete auxiliar o protegidos mediante #if DEBUG cuando no deban formar parte de una compilación de distribución. Lo importante es que los protocolos y la inyección continúen disponibles en el código de producción; son decisiones de arquitectura, no trucos exclusivos de la previsualización.
También hay que evitar datos personales, tokens y respuestas reales sin anonimizar. Que un valor solo se utilice en previews no lo excluye del repositorio ni impide que termine dentro de un artefacto de depuración. Los ejemplos deben ser ficticios y reconocibles como tales.
El resultado es un límite claro: la vista y su modelo describen el comportamiento de la aplicación, mientras que el entorno decide de dónde llegan los datos. En producción se inyecta el servicio de red; en la preview, un escenario estático en memoria. Esa pequeña separación convierte las previews en una colección fiable de estados visuales y devuelve rapidez al ciclo de diseño sin crear una segunda versión de la interfaz.