MVVM sigue siendo uno de los patrones más utilizados para estructurar aplicaciones SwiftUI, pero también uno de los que se aplican con mayor automatismo. Crear un ViewModel para cada View puede parecer coherente y, aun así, terminar repartiendo el código que maneja una pantalla sencilla entre demasiados archivos sin obtener una separación real y aumentando la complejidad.
Una idea bastante útil es empezar sin modelo de vista y extraerlo cuando aparezca lógica de presentación que merezca vivir y probarse por separado. SwiftUI ya proporciona estado, enlaces y valores del entorno; MVVM debe resolver un problema concreto, no convertirse en un requisito administrativo.
Qué separa realmente MVVM
Las tres piezas tienen responsabilidades diferentes:
- El modelo representa datos y reglas del dominio.
- La vista describe la interfaz y comunica las acciones del usuario.
- El modelo de vista adapta el dominio a la pantalla: coordina carga de datos, mantiene estados transitorios, valida entradas para su presentación y transforma errores en información que la vista pueda mostrar.
El modelo de vista no debería conocer su View. Tampoco conviene convertirlo en el lugar donde terminan gestión de red, la persistencia, las analíticas y todas las reglas de negocio. Esas operaciones pueden permanecer en servicios o repositorios inyectados; el modelo de vista se limita a coordinar el flujo y exponer su resultado.
Una vista sencilla no necesita intermediarios
Si una vista solo representa un valor, añadir otra capa suele empeorar el código:
struct TrainingSummaryView: View {
let session: TrainingSession
var body: some View {
LabeledContent(
session.name,
value: "\(session.durationMinutes) min"
)
}
}
Un TrainingSummaryViewModel que copiase name y durationMinutes no ocultaría complejidad ni facilitaría pruebas relevantes. Solo obligaría a navegar por otro tipo para llegar a los mismos datos.
La extracción empieza a compensar cuando la pantalla coordina una operación asíncrona, combina varias dependencias, presenta estados como carga y error, transforma datos del dominio o contiene decisiones que interesa verificar sin representar la interfaz.
Un ViewModel moderno con Observation
Imaginemos ahora un editor que guarda una sesión de entrenamiento. La persistencia queda detrás de un protocolo, mientras el modelo de vista mantiene únicamente el estado que necesita la pantalla:
import Foundation
import Observation
struct TrainingSession: Equatable, Sendable {
let name: String
let durationMinutes: Int
}
protocol SessionSaving: Sendable {
func save(_ session: TrainingSession) async throws
}
enum SaveState: Equatable {
case idle
case saving
case saved
case failed(String)
}
@Observable
@MainActor
final class SessionEditorModel {
var name = ""
var durationMinutes = 30
private(set) var state: SaveState = .idle
private let saver: any SessionSaving
init(saver: any SessionSaving) {
self.saver = saver
}
var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& durationMinutes > 0
}
func save() async {
guard canSave else {
state = .failed("Introduce un nombre y una duración válidos.")
return
}
state = .saving
let session = TrainingSession(
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
durationMinutes: durationMinutes
)
do {
try await saver.save(session)
state = .saved
} catch is CancellationError {
state = .idle
} catch {
state = .failed("No se pudo guardar la sesión.")
}
}
}
@Observable, introducido con Swift 5.9, permite que SwiftUI registre qué propiedades consulta cada vista y la vuelva a calcular cuando cambien precisamente esas propiedades. La macro resuelve la observación, pero no el aislamiento concurrente; @MainActor deja claro que el estado de presentación se modifica en el actor principal.
También importa el nombre de las acciones. save() expresa la intención de la pantalla. La vista no necesita saber si el guardado utiliza SwiftData, CloudKit o una petición HTTP.
Propiedad y enlaces en la vista
Cuando una vista crea su modelo de vista, @State conserva la misma instancia durante su ciclo de vida. @Bindable solo es necesario para obtener enlaces a propiedades observables:
struct SessionEditorView: View {
@State private var model: SessionEditorModel
init(saver: any SessionSaving) {
_model = State(
initialValue: SessionEditorModel(saver: saver)
)
}
var body: some View {
@Bindable var model = model
Form {
TextField("Nombre", text: $model.name)
Stepper(
"Duración: \(model.durationMinutes) min",
value: $model.durationMinutes,
in: 5...180,
step: 5
)
Button(model.state == .saving ? "Guardando…" : "Guardar") {
Task { await model.save() }
}
.disabled(!model.canSave || model.state == .saving)
if case let .failed(message) = model.state {
Text(message).foregroundStyle(.red)
}
if model.state == .saved {
Label("Sesión guardada", systemImage: "checkmark.circle")
.foregroundStyle(.green)
}
}
}
}
Si la instancia llega creada desde una vista superior, una propiedad simple como let model: SessionEditorModel basta para leerla y llamar a sus métodos. La integración de Observation con SwiftUI reserva @State para el modelo cuyo ciclo de vida pertenece a la vista, @Environment para una dependencia propagada por la jerarquía y @Bindable para crear enlaces. En código basado en ObservableObject, las equivalencias siguen siendo @StateObject para la instancia propia y @ObservedObject para la recibida.
Probar comportamiento, no la jerarquía visual
La dependencia inyectada permite comprobar el flujo sin representar ninguna vista:
import Testing
actor SessionSaverSpy: SessionSaving {
private var savedSessions: [TrainingSession] = []
func save(_ session: TrainingSession) async throws {
savedSessions.append(session)
}
func receivedSessions() -> [TrainingSession] {
savedSessions
}
}
@Test @MainActor
func savesAValidSession() async {
let saver = SessionSaverSpy()
let model = SessionEditorModel(saver: saver)
model.name = "Movilidad"
model.durationMinutes = 25
await model.save()
#expect(await saver.receivedSessions() == [
TrainingSession(name: "Movilidad", durationMinutes: 25)
])
#expect(model.state == .saved)
}
El beneficio no es alcanzar una cobertura artificial para cada View, sino aislar un comportamiento con valor propio: validación, transición de estados, tratamiento de cancelaciones y coordinación con una dependencia.
Una regla práctica para no sobrediseñar
MVVM funciona mejor en SwiftUI como una herramienta gradual. Una celda, una etiqueta o un componente puramente visual pueden recibir sus datos directamente. Una pantalla con búsquedas, guardados, paginación o varios estados de presentación probablemente se beneficie de un modelo de vista. Si este empieza a acumular reglas del dominio y detalles de infraestructura, la solución no es agrandarlo, sino devolver esas responsabilidades a tipos especializados.
La medida útil no es mantener una relación de un ViewModel por cada View, sino conseguir que cada tipo tenga una responsabilidad reconocible. SwiftUI puede encargarse de las vistas pequeñas; MVVM entra en escena cuando hace más comprensible y comprobable la parte compleja.