Posts

MVVM en SwiftUI: cuándo un ViewModel aporta valor y cuándo sobra

Arturo Rivas Arias

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.