wandres.dev
TCA CON UIKIT · más allá de SwiftUI

TCA no es SwiftUI: el store no sabe quién lo mira

Casi todo el material de TCA que existe en el mundo está escrito con vistas de SwiftUI, y de ahí nace un malentendido persistente: que la arquitectura depende del framework declarativo. No es así. El reducer, el estado, las acciones, los efectos, las dependencias y el `TestStore` no contienen una sola línea que sepa cómo se pinta un píxel; lo que SwiftUI aporta es una estrategia de sincronización, no la arquitectura. Esta lección traza con precisión la frontera entre lo que es arquitectura y lo que es capa de presentación, inventaria qué sobrevive intacto al bajar a UIKit y qué hay que reescribir, y explica por qué esa reescritura afecta a tres mecanismos concretos y a ninguno más.

⏱ 18 min

Un equipo con doscientas pantallas escritas en UIKit lee sobre TCA, se entusiasma con la testabilidad y la composición, y a los diez minutos se topa con la pregunta que decide el proyecto: para adoptar esto, ¿hay que reescribir la interfaz entera? La respuesta corta es que no, y la larga es más interesante que la corta, porque para justificarla hay que saber exactamente dónde acaba la arquitectura y dónde empieza el dibujo. El Store de TCA es una caja que guarda un valor, recibe acciones, ejecuta un reducer y despacha efectos. Nada de eso menciona una vista. Lo que sí menciona una vista es el problema de mantener la pantalla sincronizada con ese valor, y ahí SwiftUI y UIKit ofrecen respuestas radicalmente distintas: una la calcula el framework, la otra la escribes tú. Este nivel entero trata de escribirla bien.

🎯 Al terminar esta lección sabrás
  • Situar la frontera exacta entre el núcleo de TCA y la capa de presentación, y nombrar qué tipos viven a cada lado.
  • Inventariar qué código sobrevive sin cambios al pasar de SwiftUI a UIKit y comprobar que incluye la totalidad de los tests.
  • Identificar los tres mecanismos —observación, envío de acciones y navegación— que sí hay que reescribir.
  • Reconocer en la historia de ViewStore y de @ObservableState por qué la biblioteca siempre fue agnóstica de la vista.

Dónde termina la arquitectura y dónde empieza la vista

Haz el ejercicio mental de recorrer los tipos que has usado hasta aquí y preguntarte cuáles necesitan saber que existe una pantalla. Reducer es un protocolo con una función que toma estado y acción y devuelve un efecto. Effect es un valor que describe trabajo asíncrono. @Dependency es una búsqueda en un contenedor con ámbito. Scope, ifLet y forEach son operadores sobre reducers. TestStore conduce el sistema paso a paso. Ninguno de los seis necesita un píxel. El único tipo con vocación de frontera es Store, y su contrato es minúsculo: mantiene un valor de estado observable, expone send para recibir acciones y scope para producir sub-stores.

Que la biblioteca envíe además ayudantes de SwiftUI —los antiguos WithViewStore y ForEachStore, los modificadores de presentación, los bindings— no contradice nada: son una capa que se apoya en ese contrato, del mismo modo que la capa de UIKit se apoya en él. La distinción operativa que importa es esta: el núcleo define qué es verdad en cada instante; la capa de presentación resuelve cómo se refleja esa verdad en objetos de dibujo. Son dos problemas separables, y TCA los tiene separados desde su primera versión.

@Reducer
struct Contador {
  @ObservableState
  struct State: Equatable {
    var cuenta = 0
    var cargando = false
  }
  enum Action {
    case incrementarPulsado
    case sincronizarPulsado
    case datoRecibido(Int)
  }
  @Dependency(\.numeros) var numeros

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .incrementarPulsado:
        state.cuenta += 1
        return .none
      case .sincronizarPulsado:
        state.cargando = true
        return .run { send in await send(.datoRecibido(numeros.actual())) }
      case let .datoRecibido(valor):
        state.cargando = false
        state.cuenta = valor
        return .none
      }
    }
  }
}

Ese archivo es idéntico en una app de SwiftUI y en una de UIKit. No hay una versión para cada framework, ni un protocolo que conformar, ni una bandera de compilación. Es literalmente el mismo texto.

Lo que no cambia: casi todo

Merece la pena hacer el inventario explícito, porque es el argumento que convence a un equipo escéptico. Sobreviven sin tocar el State y el Action, el body del reducer con todos sus operadores, los efectos y su cancelación, el árbol completo de dependencias con sus valores de producción y de prueba, la modularización en paquetes, la composición padre-hijo y, punto decisivo, la suite de tests entera. Un TestStore no instancia vistas; conduce el reducer. Por eso los tests que escribas hoy contra una feature dibujada con UIViewController seguirán pasando mañana si esa misma feature pasa a dibujarse con SwiftUI, sin editar una línea.

ℹ️
El criterio para saber si algo es arquitectura o presentación

Pregúntate si el tipo aparecería en un test. State, Action, Reducer, Effect y las dependencias aparecen; UILabel, Text, UITableView y List no. Si un fragmento de lógica solo se puede ejercitar montando una jerarquía de vistas, ese fragmento está en el lado equivocado de la frontera, y el problema no es de UIKit ni de SwiftUI sino de reparto de responsabilidades.

Lo que sí cambia: el bucle de sincronización

Lo que cambia es una sola cosa, aunque se manifieste de tres maneras. SwiftUI mantiene la pantalla sincronizada recalculando una función: cuando el estado leído por un body cambia, el framework invalida esa vista y vuelve a evaluarla, y después reconcilia el árbol resultante contra el anterior. UIKit no tiene esa función ni ese árbol. Una vista de UIKit es un objeto mutable con propiedades que alguien tiene que asignar, y no existe ningún momento en el que el sistema decida volver a preguntarte cómo debería verse la pantalla. Ese hueco es todo el trabajo del nivel.

struct ContadorView: View {
  let store: StoreOf<Contador>

  var body: some View {
    VStack {
      Text("\(store.cuenta)")
      Button("Incrementar") { store.send(.incrementarPulsado) }
      if store.cargando { ProgressView() }
    }
  }
}
final class ContadorViewController: UIViewController {
  private let store: StoreOf<Contador>
  private let etiqueta = UILabel()
  private let indicador = UIActivityIndicatorView()

  init(store: StoreOf<Contador>) {
    self.store = store
    super.init(nibName: nil, bundle: nil)
  }

  override func viewDidLoad() {
    super.viewDidLoad()
    observe { [weak self] in
      guard let self else { return }
      etiqueta.text = "\(store.cuenta)"
      indicador.isHidden = !store.cargando
    }
  }
}

Los dos ficheros hablan con el mismo store y expresan la misma relación entre estado y pantalla. La diferencia es quién ejecuta esa relación: arriba la ejecuta el framework cada vez que lo cree necesario, abajo la ejecuta una closure que tú registras y que TCA vuelve a invocar cuando cambia algo que esa closure leyó. Las tres manifestaciones del cambio son entonces predecibles, y las tres ocupan una lección de este nivel.

👁️

Observar

SwiftUI reevalúa un body; en UIKit registras un bloque con observe que asigna propiedades sobre vistas ya construidas.

📮

Enviar

Los gestos declarativos se sustituyen por target-action, UIAction y métodos de delegado, todos terminando en store.send.

🧭

Navegar

Los modificadores dirigidos por estado se sustituyen por un reconciliador que llama a present y a pushViewController.

🧬

Nada más

Reducer, efectos, dependencias, composición y tests permanecen idénticos. Si algo más cambia, es que se filtró dominio a la vista.

Conviene fijar esa lista porque acota el presupuesto de la migración con una precisión inusual: lo que hay que reescribir es el archivo de la vista, y solo el archivo de la vista. La cuarta tarjeta funciona además como prueba diagnóstica. Si al portar una pantalla a UIKit descubres que también hay que tocar el reducer, no has encontrado una limitación del framework: has encontrado un trozo de lógica que vivía en la vista de SwiftUI y que la migración acaba de sacar a la luz.

flowchart TD
N[Nucleo con Reducer y State y Action y Effect] --> S[Store]
D[Dependencias inyectadas] --> S
S --> P1[Capa SwiftUI con body recalculado]
S --> P2[Capa UIKit con closure observe]
P1 --> V1[Arbol declarativo reconciliado por el framework]
P2 --> V2[Objetos mutables asignados por ti]
S --> T[TestStore sin capa de presentacion]
style S fill:#89b4fa,color:#11111b
💡
La historia explica el diseño

Antes de @ObservableState, TCA exponía ViewStore, un objeto con publicadores de Combine. La razón de esa forma es exactamente la de este nivel: un publicador sirve igual a un body de SwiftUI que a un sink dentro de un UIViewController. La biblioteca nunca fue declarativa por dentro; era observable, que es una propiedad más débil y por eso más portátil. Al llegar el framework Observation, ViewStore se retiró y en su lugar quedaron dos consumidores del mismo mecanismo, body y observe.

La arquitectura no es el dibujo: es la dirección en la que viaja la información

La confusión entre TCA y SwiftUI tiene una causa concreta y vale la pena nombrarla, porque desmontarla cambia cómo evalúas cualquier arquitectura futura. SwiftUI popularizó la idea de que la interfaz es una función del estado, y TCA se apoyó en esa idea con tanta comodidad que muchos concluyeron que era la misma idea. Pero la ecuación de SwiftUI y la de TCA operan en planos distintos. La de SwiftUI es una afirmación sobre el dibujo: dado un valor, existe un árbol de vistas determinado, y el framework se encarga de que la pantalla converja a ese árbol. La de TCA es una afirmación sobre la causalidad: el estado solo cambia dentro de un reducer, y el reducer solo se invoca con una acción, de modo que todo cambio del sistema tiene un nombre, un origen rastreable y un momento. Lo primero es una técnica de renderizado; lo segundo es una restricción sobre el flujo de información, y solo lo segundo es arquitectura. Que ambas se lleven bien no es casualidad —una función del estado necesita que el estado esté en un sitio, y la unidireccionalidad garantiza que lo esté— pero tampoco es identidad, y al separarlas se ve que la unidireccionalidad no exige un renderizador declarativo, sino únicamente que exista alguna forma de enterarse de que el valor cambió. UIKit ofrece esa forma desde siempre, con KVO, con delegados, con Combine y ahora con Observation. Por eso el ejercicio de este nivel no es una concesión nostálgica a un framework antiguo, sino la prueba empírica de la tesis: si al cambiar la tecnología de dibujo el reducer, los efectos, las dependencias y los tests permanecen literalmente idénticos, entonces el reducer, los efectos, las dependencias y los tests nunca dependieron del dibujo. Y esa independencia, medida en líneas que no se tocan, es el valor que compras al aceptar la disciplina de la unidireccionalidad. Lo demás —la sintaxis del body, la ergonomía de los modificadores, el rendimiento del diffing— es tecnología de presentación, importante, reemplazable y por completo ajena a las decisiones que estructuran una aplicación.

⚔️ Demuestra la independencia con tu propio código
  1. Elige una feature pequeña de una app tuya en TCA y lista sus archivos, marcando cuáles mencionan SwiftUI en un import.
  2. Verifica que ni el reducer ni el archivo de tests lo mencionan. Si alguno lo hace, averigua qué tipo de presentación se ha colado en el dominio.
  3. Escribe un UIViewController mínimo que reciba el StoreOf de esa feature y muestre un solo dato en un UILabel usando observe.
  4. Ejecuta la suite de tests existente sin modificarla y confirma que sigue verde con la pantalla nueva conviviendo con la antigua.
  5. Redacta en tres frases, para tu equipo, qué habría que reescribir si mañana se decidiera migrar la app entera a UIKit. Contrasta tu lista con las tres manifestaciones del cierre de esta lección.