Saltar al contenido
← Todos los posts

Estado y arquitectura en SwiftUI: de Zustand y Redux a @Observable y TCA

Cómo manejar estado en SwiftUI viniendo de Zustand y Redux: MVVM con @Observable como default, cuándo TCA justifica su curva, inyección de dependencias con swift-dependencies o Factory y por qué modularizar en packages desde el día uno.

Ilustración de un estado central del que salen vistas que solo observan la parte que leen, con módulos separados alrededor

En React Native la conversación sobre estado ya está resuelta para la mayoría de los equipos: estado local con hooks, estado de servidor con TanStack Query, estado global con Zustand o Redux Toolkit según el tamaño. En SwiftUI la conversación existe, pero con otros nombres y con una discusión que en la comunidad sigue abierta: si hace falta un ViewModel o si la vista puede hablar con el modelo directamente. Este post da mi posición, basada en proyectos que empezaron pequeños y crecieron: @Observable con MVVM como default, TCA solo cuando el proyecto justifica su curva de aprendizaje, dependencias inyectadas con una librería desde el primer día, y el código partido en packages de Swift Package Manager antes de que el archivo de proyecto se vuelva un problema.

Resumen para perezosos
  • Una clase @Observable es el reemplazo de un store de Zustand: estado compartido, observación por propiedad y sin selectores. Un ViewModel por pantalla con esa clase es el default que escala sin ceremonia.
  • TCA (The Composable Architecture) es Redux con efectos, cancelación y tests exhaustivos integrados. Vale la pena cuando el equipo es grande, el dominio es complejo y vas a testear cada transición; para una app de tamaño medio, su curva cuesta más de lo que devuelve.
  • Inyecta dependencias desde el día uno con swift-dependencies o Factory, y parte la app en packages de SPM por capa. Los dos hábitos son baratos al inicio y carísimos de agregar después.

En este artículo:

Qué reemplaza a qué: el mapa de estado

Antes de discutir arquitectura conviene fijar el vocabulario, porque el estado en SwiftUI se reparte en más lugares que en React y cada uno tiene su herramienta.

Tipo de estadoReact NativeSwiftUI
Local de la vista (un toggle, un campo)useState@State con un struct o valor simple
Compartido entre vistas hermanasLevantar estado + props@State en el padre + @Binding en los hijos
De una pantalla (carga, formulario, errores)useReducer o un hook customUn ViewModel @Observable creado por la pantalla
Global de la app (sesión, carrito, ajustes)Zustand / ReduxUna clase @Observable inyectada con .environment
De servidor (caché de red)TanStack QueryNo hay equivalente estándar; tema del post de data
Persistido (preferencias)AsyncStorage + store@AppStorage para valores simples; SwiftData o GRDB para el resto
De navegaciónExpo RouterNavigationStack con un path observable; tema del post de navegación

La diferencia más grande con React Native es que el estado global no necesita una librería. @Observable viene con el sistema desde iOS 17 y hace lo que Zustand hacía por ti: exponer un objeto mutable cuyos cambios re-evalúan solo las vistas que leen las propiedades afectadas.

@Observable: el store que viene con el sistema

Un store de Zustand es un objeto con estado y funciones que lo mutan, y un componente se suscribe a una parte con un selector para no re-renderizar por cambios que no le importan. La traducción a Swift es una clase marcada con el macro @Observable:

import Observation

@Observable
final class CartStore {
    private(set) var items: [CartItem] = []
    var coupon: String?

    var total: Decimal {
        items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
    }

    func add(_ product: Product) {
        if let index = items.firstIndex(where: { $0.productId == product.id }) {
            items[index].quantity += 1
        } else {
            items.append(CartItem(product: product))
        }
    }

    func remove(productId: Product.ID) {
        items.removeAll { $0.productId == productId }
    }
}

Y su uso desde una vista, sin selector:

struct CartBadge: View {
    @Environment(CartStore.self) private var cart

    var body: some View {
        // Esta vista solo se re-evalúa cuando cambia `items`.
        // Un cambio en `coupon` no la toca, sin selectores ni memo.
        Text("\(cart.items.count)")
    }
}

El macro instrumenta cada propiedad para registrar qué vista la leyó durante body. Es el comportamiento de un selector de Zustand, pero automático y con granularidad de propiedad. No hay useShallow, no hay useStore((s) => s.items.length), no hay React.memo. Tampoco hay ObservableObject con @Published y objectWillChange, que era la API anterior y que todavía vas a ver en proyectos y ejemplos: con ese modelo cualquier cambio en cualquier propiedad re-evaluaba todas las vistas suscritas al objeto, y esa era una fuente frecuente de re-evaluaciones innecesarias antes de iOS 17.

Tres decisiones prácticas:

  • private(set) en lo que solo el store puede mutar. Igual que en Zustand exponías acciones en vez de dejar que el componente hiciera set directo. La vista lee items y llama a add, no toca el array.
  • Se inyecta con .environment(cartStore) en la raíz y se lee con @Environment(CartStore.self). Un solo store global por dominio (sesión, carrito, ajustes), no un store gigante con todo.
  • Para escribir con Binding sobre una propiedad del store desde un TextField o un Toggle, se usa @Bindable var cart = cart dentro de body, y de ahí $cart.coupon.

MVVM o MV: la discusión que vas a tener

En la comunidad de SwiftUI hay una discusión de años sobre si el ViewModel hace falta. Un lado (MV, “Model-View”) sostiene que SwiftUI ya es un binding entre modelo y vista, que los property wrappers son la capa de presentación, y que meter un ViewModel por pantalla es cargar con una ceremonia heredada de UIKit. El otro lado (MVVM) sostiene que la vista debería tener cero lógica y que un objeto testeable por pantalla es lo que mantiene el proyecto sano cuando hay cinco personas tocándolo.

Mi posición, después de haber hecho las dos: MVVM con @Observable, pero ligero. Un ViewModel por pantalla que:

  • Expone el estado que la vista necesita, ya transformado (strings formateados, listas ordenadas, flags de carga).
  • Recibe las acciones del usuario como métodos (load(), submit(), delete(id:)).
  • Habla con los repositorios y stores, no con la red ni con la base de datos directamente.
  • No importa SwiftUI. Si el ViewModel necesita import SwiftUI, algo de presentación se filtró donde no va.
@Observable
@MainActor
final class OrdersViewModel {
    private(set) var state: Loadable<[Order]> = .idle
    var query = ""

    private let repository: OrderRepository

    init(repository: OrderRepository) {
        self.repository = repository
    }

    var visibleOrders: [Order] {
        guard case .loaded(let orders) = state else { return [] }
        guard !query.isEmpty else { return orders }
        return orders.filter { $0.title.localizedCaseInsensitiveContains(query) }
    }

    func load() async {
        state = .loading
        do {
            state = .loaded(try await repository.fetchAll())
        } catch {
            state = .failed(error)
        }
    }
}
struct OrdersScreen: View {
    @State private var model: OrdersViewModel

    init(repository: OrderRepository) {
        _model = State(initialValue: OrdersViewModel(repository: repository))
    }

    var body: some View {
        List(model.visibleOrders) { order in
            OrderRow(order: order)
        }
        .searchable(text: Bindable(model).query)
        .overlay { if case .loading = model.state { ProgressView() } }
        .task { await model.load() }
    }
}

El argumento de MV que sí acepto: para una vista pequeña con dos estados locales, un ViewModel es ruido. La regla que uso es que el ViewModel aparece cuando la pantalla tiene una carga asíncrona, más de un estado derivado o lógica que quiero testear sin SwiftUI. Un Toggle con un @State no necesita ViewModel; una lista de pedidos con búsqueda, filtro y borrado, sí.

Lo que evito en las dos escuelas: vistas que llaman a URLSession directamente, ViewModels que conocen NavigationPath, y stores globales con propiedades de UI (isSheetPresented no va en CartStore).

Cuándo TCA justifica su curva

The Composable Architecture, de Point-Free, es la respuesta de Swift a Redux, y si vienes de Redux Toolkit la vas a reconocer al instante: un State (struct), una Action (enum con datos asociados), un Reducer que produce el nuevo estado y devuelve efectos, y un Store que lo une todo. Sobre esa base agrega lo que en Redux resolvías con middlewares y librerías aparte: efectos asíncronos como valores de primera clase, cancelación por id, composición de reducers para pantallas hijas, navegación modelada en el estado, y un TestStore que verifica cada transición de estado y cada efecto de forma exhaustiva.

@Reducer
struct OrdersFeature {
    @ObservableState
    struct State: Equatable {
        var orders: [Order] = []
        var isLoading = false
        var query = ""
    }

    enum Action {
        case task
        case ordersResponse(Result<[Order], Error>)
        case queryChanged(String)
    }

    @Dependency(\.orderClient) var orderClient

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .task:
                state.isLoading = true
                return .run { send in
                    await send(.ordersResponse(Result { try await orderClient.fetchAll() }))
                }
            case .ordersResponse(.success(let orders)):
                state.isLoading = false
                state.orders = orders
                return .none
            case .ordersResponse(.failure):
                state.isLoading = false
                return .none
            case .queryChanged(let query):
                state.query = query
                return .none
            }
        }
    }
}

Lo que da a cambio de esa estructura:

  • Tests exhaustivos. TestStore falla si el estado cambia de una forma que el test no declaró, o si un efecto queda pendiente. Es el nivel de garantía que en Redux exigía disciplina y aquí exige el framework.
  • Composición. Una pantalla hija es un reducer que se incrusta en el padre con Scope, con su estado y acciones incluidos en los del padre. Escala a decenas de pantallas sin que ninguna sepa de las otras.
  • Navegación como estado. Sheets, alerts y pushes viven en State como opcionales o como un stack, y se testean igual que cualquier otro cambio.

Lo que cuesta:

  • La curva. Macros, Scope, IdentifiedArray, @Presents, StackState, Effect.run con send. Son dos o tres semanas para que un senior lo escriba con soltura, y el resto del equipo tiene que pasar por lo mismo.
  • Boilerplate real. Un Toggle es una acción, un caso en el reducer y una línea en el test. Para pantallas simples, la relación entre código y valor es mala.
  • Dependencia de un tercero para el corazón de la app, con actualizaciones que a veces piden migraciones.

Mi regla: TCA cuando el equipo es de tres o más personas, el dominio tiene estados con muchas transiciones (checkout, onboarding con ramas, editores) y los tests de esas transiciones son un requisito, no un deseo. Para todo lo demás, MVVM con @Observable. Y un dato que ayuda a decidir: las mismas personas que hacen TCA publican swift-dependencies, swift-navigation y otras librerías que funcionan sin TCA, así que puedes adoptar sus ideas sin adoptar el framework completo.

¿Necesito TCA?

  ¿Equipo de 3+ y dominio con estados complejos?

     ├── no ──► MVVM con @Observable

     └── sí

          ¿Testear cada transición es requisito?

             ├── no ──► MVVM con @Observable + swift-dependencies

             └── sí ──► TCA

Inyección de dependencias: swift-dependencies o Factory

En React Native, la inyección de dependencias rara vez se formaliza: se importa el cliente de API, se mockea con jest.mock en los tests y se sigue. En Swift no hay jest.mock: si un ViewModel construye un cliente concreto, el test usa el cliente concreto. Por eso la inyección aparece el primer día. No hace falta una librería para eso (inicializadores que reciben protocolos, closures o una factory escrita a mano funcionan bien), pero cuando el proyecto pasa de tres pantallas conviene sistematizarla, y las dos librerías que uso para eso son estas.

swift-dependencies (Point-Free, también usable sin TCA). Declaras cada dependencia como una clave, das una implementación real y una de test, y la lees con @Dependency donde la necesites:

import Dependencies

struct OrderClient: Sendable {
    var fetchAll: @Sendable () async throws -> [Order]
}

extension OrderClient: DependencyKey {
    static let liveValue = OrderClient(
        fetchAll: { try await APIClient.shared.get("/orders") }
    )
    static let testValue = OrderClient(
        fetchAll: { [.fixture()] }
    )
}

extension DependencyValues {
    var orderClient: OrderClient {
        get { self[OrderClient.self] }
        set { self[OrderClient.self] = newValue }
    }
}

En un test, withDependencies { $0.orderClient.fetchAll = { throw URLError(.notConnectedToInternet) } } y el ViewModel recibe esa versión sin que el código de producción sepa nada. Una decisión de diseño que vale la pena copiar: la dependencia es un struct con closures, no un protocolo. Para el test se sobrescribe solo la closure que importa.

Factory (Michael Long). Un contenedor de registros con una sintaxis más corta y resolución perezosa:

import FactoryKit

extension Container {
    var orderRepository: Factory<OrderRepository> {
        self { RemoteOrderRepository(client: self.apiClient()) }
            .singleton
    }
}

// En el ViewModel
@Injected(\.orderRepository) private var repository

Y en tests, Container.shared.orderRepository.register { FakeOrderRepository() }.

Cuál elegir: swift-dependencies si valoras el control de test estricto (falla si una dependencia se usa sin declarar) y la afinidad con TCA; Factory si prefieres algo más directo, con scopes (singleton, cached, shared) y menos ceremonia. Las dos son mejores que el patrón “singleton con shared y un init(client:) para tests” que todos escribimos primero y que deja de escalar en la tercera pantalla.

Modularizar en packages desde el día uno

En React Native, un monorepo con packages es una decisión que se toma cuando el proyecto crece. En Swift mi recomendación es al revés: partir en packages de Swift Package Manager desde el primer commit. Apple documenta los packages locales como la forma de modularizar una app; las tres razones por las que yo lo hago desde el principio son estas, y la primera es exclusiva de Xcode.

La primera es el archivo de proyecto. Cada archivo que agregas a un target de Xcode modifica project.pbxproj, y dos personas agregando archivos en ramas distintas producen conflictos de merge en un formato que nadie quiere resolver a mano. Un package de SPM se describe en Package.swift y toma sus archivos del sistema de archivos: agregar un archivo es agregar un archivo.

La segunda son los tiempos de compilación. Swift compila por módulo, y un cambio en un módulo recompila ese módulo y los que dependen de él, así que un grafo de dependencias en una sola dirección acota lo que se recompila: con packages por capa, un cambio en la UI de pedidos no toca la red ni la persistencia. No es una garantía automática (el resultado depende del grafo, de los genéricos y macros que crucen módulos y de la configuración del build, y partir de más también puede empeorarlo), pero es la palanca más grande que he encontrado, y el post de tooling explica cómo medirla.

La tercera es la visibilidad. Sin módulos, todo es internal y accesible desde cualquier parte; la arquitectura se sostiene por convención. Con packages, public es una decisión explícita, y una vista no puede importar URLSession si el package de UI no depende del de red.

La estructura mínima que uso:

MiApp/
├── App/                    ← target de Xcode: solo el @main y la composición
└── Packages/
    ├── Domain/             ← structs, enums, protocolos de repositorio. Sin dependencias.
    ├── Networking/         ← URLSession, DTOs, Codable. Depende de Domain.
    ├── Persistence/        ← SwiftData o GRDB. Depende de Domain.
    ├── DesignSystem/       ← colores, tipografía, componentes base. Sin dependencias.
    └── Features/
        ├── Orders/         ← vistas + ViewModels. Depende de Domain y DesignSystem.
        └── Checkout/

Con Package.swift en Packages/ declarando cada uno como target de un mismo package, o como packages separados si el proyecto es grande. El target de Xcode queda casi vacío: el @main, el árbol de .environment(...) y la elección de implementaciones reales para cada dependencia.

En un proyecto que empezó con un solo target y creció durante un año, el costo de partirlo en packages después fue de varias semanas de trabajo repartidas entre mover archivos, resolver ciclos de dependencias que nadie sabía que existían y volver public cientos de tipos. En el siguiente proyecto lo hice el primer día y tomó una tarde.

Si prefieres no escribir el .pbxproj nunca, Tuist genera el proyecto desde una descripción en Swift y elimina los conflictos por completo; el post de build y firma lo cubre. Pero incluso sin Tuist, los packages ya resuelven la mayor parte del problema.

Preguntas frecuentes

¿@Observable reemplaza por completo a ObservableObject y @Published?

Para código nuevo con iOS 17 o superior, sí. ObservableObject sigue existiendo para compatibilidad y para algunos casos con Combine, pero el rendimiento y la ergonomía de @Observable son mejores. Si heredas un proyecto con @StateObject y @ObservedObject, se migra clase por clase sin cambiar el resto.

¿Dónde vive el estado de “hay sesión iniciada”?

En un store global @Observable (SessionStore) inyectado en la raíz con .environment. La vista raíz observa session.user y decide si muestra el login o la app. Es el equivalente de un AuthProvider de React, con la diferencia de que no necesita un contexto aparte del objeto.

¿Puedo mezclar TCA en una parte de la app y MVVM en el resto?

Sí, y es una forma razonable de adoptarlo: TCA en la funcionalidad compleja (un checkout, un editor) y MVVM en el resto. El Store de TCA se puede crear desde cualquier vista de SwiftUI. Lo que cuesta es mantener dos formas de hacer lo mismo en el equipo, así que conviene que la frontera sea clara y documentada.

¿Cómo comparto lógica entre pantallas sin un ViewModel gigante?

Con stores por dominio (CartStore, SessionStore) para lo que es global, y con casos de uso o repositorios inyectados para la lógica que varias pantallas ejecutan. Un ViewModel no debería llamar a otro ViewModel; los dos llaman al mismo repositorio.

¿Los packages de SPM dificultan las Previews o los tests?

No. Cada package puede tener su propio target de tests, que en mi experiencia corre más rápido que los tests del target de la app porque compila solo ese módulo y sus dependencias. Las Previews funcionan dentro del package, con datos de ejemplo del mismo módulo, y por el mismo motivo suelen ser más rápidas que en la app completa.

Conclusión

El estado en SwiftUI no necesita una librería para lo que Zustand hacía: @Observable es un store con observación por propiedad que viene con el sistema. Sobre eso, MVVM ligero es el default que escala: un ViewModel por pantalla con carga o lógica, ninguno para vistas triviales, y nunca una vista hablando con la red. TCA es Redux con efectos y tests exhaustivos integrados, y vale su curva solo cuando equipo, dominio y requisitos de test la justifican. Las dos decisiones que no admiten espera son la inyección de dependencias (swift-dependencies o Factory) y la partición en packages, porque el costo de agregarlas después se mide en semanas.

Para empezar: crea los packages de dominio, red y una feature el primer día; escribe el primer store @Observable y el primer ViewModel con su dependencia inyectada; y no toques TCA hasta tener una pantalla cuya complejidad te haga extrañar un reducer. El siguiente post cubre lo que este dejó fuera a propósito: dónde vive el estado de navegación cuando no hay carpetas que definan las rutas.

Seguir leyendo