wandres.dev
NIVEL DIOS: SÍNTESIS · componer todo

El modelo mental completo: cinco piezas, una composición y una frontera

Todo el track cabe en una sola imagen y esta lección la dibuja entera: las cinco piezas cerrando un ciclo determinista, la composición entendida como un álgebra cerrada donde combinar reducers vuelve a dar un reducer, y las dependencias como la única frontera por la que el mundo tiene permitido entrar. De ahí se deduce el invariante del que se siguen todas las promesas de TCA —testabilidad, navegación como dato, modularidad— y queda claro por qué ninguna de las tres es una característica añadida al framework sino un corolario de haber respetado ese invariante en cada línea.

⏱ 23 min

Has recorrido treinta niveles y en cada uno había una pieza ocupando el centro de la pantalla. Esta lección hace la operación inversa: alejar la cámara hasta que las treinta caben en un solo plano y se ve, por fin, que nunca fueron treinta ideas sino una sola aplicada a distintas escalas. El objetivo no es repasar —repasar es releer— sino comprimir: quedarte con la imagen mínima desde la que puedas re-derivar cualquier detalle del framework aunque olvides el nombre exacto del operador. Si al terminar puedes dibujar el ciclo en una servilleta y explicar por qué el testing exhaustivo se sigue de él en lugar de añadirse a él, el track entero ha cumplido su función.

🎯 Al terminar esta lección sabrás
  • Reconstruir de memoria el ciclo cerrado de las cinco piezas y decir qué garantiza exactamente cada una.
  • Ver la composición como un álgebra cerrada: combinar reducers produce siempre otro reducer, a cualquier escala.
  • Situar @Dependency y Effect como las dos mitades de la única frontera legítima con el mundo.
  • Deducir el invariante único del que se siguen la testabilidad, la navegación como dato y la modularidad.

Las cinco piezas, en una sola respiración

State es el valor que contiene todo lo que la feature sabe. Action es el enum que enumera todo lo que le puede ocurrir. El Reducer es la función pura que traduce lo segundo en una mutación de lo primero y en una descripción del trabajo pendiente. Effect es esa descripción: un valor, no una ejecución. Y el Store es el runtime que cierra el ciclo, ejecutando efectos y devolviendo sus resultados en forma de acciones nuevas. La feature completa, con las cinco piezas visibles a la vez, cabe en una pantalla.

@Reducer
struct Perfil {
  @ObservableState
  struct State: Equatable {          // 1. todo lo que la feature sabe
    var usuario: Usuario?
    var cargando = false
  }

  enum Action: Equatable {           // 2. todo lo que le puede pasar
    case aparecio
    case respuesta(Result<Usuario, Error>)
  }

  @Dependency(\.api) var api         // la unica puerta al exterior

  var body: some ReducerOf<Self> {   // 3. la funcion pura
    Reduce { state, action in
      switch action {
      case .aparecio:
        state.cargando = true        // mutacion sincrona, aqui y ahora
        return .run { send in        // 4. descripcion de trabajo futuro
          let usuario = try await api.perfil()
          await send(.respuesta(.success(usuario)))
        } catch: { error, send in
          await send(.respuesta(.failure(error)))
        }
      case let .respuesta(resultado):
        state.cargando = false
        state.usuario = try? resultado.get()
        return .none
      }
    }
  }
}

Lo importante de este bloque no es lo que contiene sino lo que no puede contener. No hay ninguna llamada de red suelta, ningún Date(), ningún Task detached, ningún singleton. El reducer no hace: decide y describe. El Store, que es la única pieza que no escribes tú, se encarga de que lo descrito ocurra y de que su resultado vuelva a entrar por la puerta principal, como una acción más, indistinguible de la que produjo un toque del usuario.

Pieza Qué es, en tipos Quién la escribe Qué garantiza
State Una struct de valores Tú, modelando el dominio Copiar aísla, comparar es barato, nadie muta a tu espalda
Action Un enum exhaustivo Tú, enumerando lo que pasa El compilador te obliga a considerar todos los casos
Reducer (inout State, Action) que devuelve Effect Tú, sin tocar el mundo Misma entrada, misma salida, siempre
Effect Un valor que describe trabajo Tú, declarándolo Lo asíncrono se vuelve inspeccionable y cancelable
Store Una clase de referencia La biblioteca El ciclo se cierra y la vista observa solo lo que lee
flowchart TD
V[Vista] -->|send de una accion| S[Store]
S -->|inout state y action| R[Reducer]
R -->|muta| ST[State]
R -->|devuelve| E[Effect]
E -->|send del resultado| S
R -->|lee| D[Dependencies]
D -->|toca| W[Mundo exterior]
ST -->|observacion granular| V
style R fill:#89b4fa,color:#11111b
style E fill:#fab387,color:#11111b
style D fill:#a6e3a1,color:#11111b

La composición es un álgebra cerrada

La palabra que da nombre a la arquitectura no describe una virtud sino una propiedad algebraica: el conjunto de los reducers es cerrado bajo composición. Combinar dos reducers no produce un coordinador, ni un contenedor, ni una capa nueva con reglas propias; produce otro reducer, con exactamente la misma firma que los dos que lo formaron. Por eso una app de cincuenta pantallas no es cincuenta arquitecturas conviviendo, sino un único valor anidado que se monta y se desmonta por partes.

@Reducer
struct App {
  @ObservableState
  struct State: Equatable {
    var perfil = Perfil.State()                              // hijo fijo
    var tareas: IdentifiedArrayOf<Tarea.State> = []          // hijos por id
    @Presents var hoja: Hoja.State?                          // hijo opcional
    var ruta = StackState<Ruta.State>()                      // hijos en pila
  }

  var body: some ReducerOf<Self> {
    Scope(state: \.perfil, action: \.perfil) { Perfil() }
    Reduce { state, action in .none }   // solo lo que ningun hijo decide solo
    .forEach(\.tareas, action: \.tareas) { Tarea() }
    .ifLet(\.$hoja, action: \.hoja) { Hoja() }
    .forEach(\.ruta, action: \.ruta) { Ruta() }
  }
}

Los cuatro operadores responden a las cuatro relaciones que un padre puede tener con sus hijos —uno siempre presente, muchos identificados, uno que puede no existir, muchos apilados— y los cuatro hacen lo mismo por dentro: extraen el trozo de estado con un key path, extraen el trozo de acción con un case path, ejecutan el reducer hijo y vuelven a envolver los efectos que devuelva. Aprendidos los cuatro, no queda un quinto por aprender: queda combinarlos. Y hay un corolario práctico que ahorra discusiones de diseño: cuando una pieza de tu app se resiste a entrar en el body, casi siempre es porque no era lógica de estado. Un formateador es una función pura y vive en un extension; una animación es presentación y vive en la vista; un cliente de red es mundo exterior y vive en una dependencia. La prueba es mecánica: si no puedes escribirlo como algo que recibe estado y acción y devuelve un efecto, no pertenece al reducer, y forzarlo solo acopla lo que ya estaba separado.

🔱

Cerrada bajo composición

Componer reducers da un reducer. Esa clausura es la que permite anidar sin inventar conceptos nuevos en cada nivel.

🗝️

Dos proyecciones

Un key path proyecta el estado hacia abajo, un case path proyecta la acción hacia arriba. Todo operador es ese par.

🧭

El padre traduce

Los hijos no se conocen entre sí. Hablan hacia arriba con delegate y el padre decide qué significa lo que dijeron.

🧱

Escala invariante

El reducer de la app y el de un contador tienen la misma firma. Lo que cambia es el tamaño del valor, no la forma.

La frontera: efectos y dependencias

El mundo entra por una sola puerta y esa puerta tiene dos hojas. Effect responde a cuándo ocurre algo que no es instantáneo: lo aparta del reducer y lo convierte en un valor que el Store ejecutará. @Dependency responde a qué concreto se ejecuta: lo aparta del código y lo convierte en un valor que el contexto decide. Sin la primera, el reducer dejaría de ser síncrono; sin la segunda, dejaría de ser determinista. Se necesitan las dos.

@DependencyClient
struct APIClient {
  var perfil: () async throws -> Usuario
}

extension APIClient: DependencyKey {
  static let liveValue = APIClient(perfil: { try await Red.pedirPerfil() })
  static let previewValue = APIClient(perfil: { .ejemplo })
  // testValue lo genera la macro: cada endpoint falla si el test no lo sobrescribe
}

Que el valor por defecto en tests sea una implementación que falla no es una comodidad: es la que convierte cada test en una declaración explícita de qué partes del mundo participan. Si el test pasa, sabes que lo hizo con exactamente las dependencias que nombraste, porque cualquier otra habría detonado. Y como el reducer es puro y las dependencias son valores, el TestStore puede afirmar el estado carácter a carácter y quedarse esperando cada efecto pendiente.

let store = TestStore(initialState: Perfil.State()) {
  Perfil()
} withDependencies: {
  $0.api.perfil = { .ejemplo }        // el unico trozo de mundo permitido
}

await store.send(.aparecio) { $0.cargando = true }
await store.receive(\.respuesta.success) {
  $0.cargando = false
  $0.usuario = .ejemplo
}
⚠️
Cada fuga de la frontera cuesta un test intermitente

Un Date(), un UUID(), un Task.sleep, un URLSession.shared o un UserDefaults.standard escritos directamente dentro de un reducer no rompen la compilación ni la app: rompen la reproducibilidad, y lo hacen semanas después, en la máquina de otra persona, en un test que falla una de cada treinta veces. La frontera no se defiende con disciplina moral sino con inventario: si algo no se puede sustituir desde withDependencies, todavía no está dentro de la arquitectura.

El invariante del que todo se deduce

Toda la arquitectura se puede comprimir en una frase que se lee como una definición y funciona como una ley: todo lo que la feature sabe está en State, todo lo que le puede pasar está en Action, todo lo que hace está en el reducer, y todo lo que el mundo aporta entra por las dependencias. Nada más. Las cuatro cláusulas son afirmaciones de exhaustividad, y de su exhaustividad se sigue todo lo demás.

Se sigue el testing determinista, porque si el estado está completo y las entradas están enumeradas, reproducir una ejecución es reproducir una secuencia de valores. Se sigue la navegación como dato, porque estar en la pantalla de detalle es algo que la feature sabe y por tanto no puede vivir en ningún otro sitio que no sea el estado. Se sigue el deep linking gratis, porque construir un estado a mano es todo lo que hace falta para arrancar en cualquier punto del árbol. Se sigue la modularidad, porque una feature cuyo mundo entra por dependencias no necesita conocer a nadie para compilar. Y se sigue el viaje en el tiempo, porque una secuencia de valores se puede guardar y volver a recorrer.

TCA no es un framework con muchas ideas: es una idea sostenida sin excepciones

La tentación al terminar un track largo es guardar un inventario: treinta operadores, veinte macros, quince tipos con sus firmas. Ese inventario caduca. Lo que no caduca es la observación de que TCA no añadió ideas a lo largo del camino, sino que se limitó a no hacer ninguna excepción a la que tenía en la primera lección. Cada pieza que parecía nueva era esa misma idea encontrando un sitio donde todavía no se había aplicado. La navegación parecía un dominio aparte y resultó ser estado que nadie había querido modelar. Las dependencias parecían infraestructura y resultaron ser argumentos implícitos que se habían colado en el cuerpo de las funciones. La cancelación parecía gestión de recursos y resultó ser el ciclo de vida de un valor. @Shared parecía una concesión pragmática al estado global y resultó ser una referencia con semántica de valor observable y testeable. En cada caso el movimiento fue idéntico: coger algo que la industria trata como efecto, comportamiento o infraestructura, y convertirlo en dato. Ese es el gesto, y es el único que hay que llevarse. Lo interesante es el precio, porque explica por qué esta arquitectura divide tanto: convertir algo en dato significa nombrarlo, y nombrar cuesta. Hay que escribir el caso del enum, el campo de la struct, la clave de la dependencia. Quien mide productividad en líneas escritas por hora concluye, con toda la razón aparente del mundo, que TCA es lenta. Quien la mide en horas de depuración a las tres de la madrugada llega a la conclusión contraria, porque un bug de estado en un sistema donde el estado es un valor enumerado no es una investigación sino una lectura. La disputa entre ambos no se resuelve con argumentos, se resuelve con el tamaño del sistema y con cuánto tiempo va a vivir. Y hay un corolario final que conviene guardar por encima de todos los detalles del framework: en cuanto interiorizas que la pregunta útil ante cualquier problema de UI es qué parte de esto estoy tratando como comportamiento cuando en realidad es un dato que no he querido nombrar, TCA se vuelve prescindible. No porque deje de servir, sino porque ya no la necesitas para pensar así. Ese, y no el dominio de su API, es el resultado que este track perseguía.

⚔️ Comprime el track en una servilleta y verifícalo contra el código
  1. Sin abrir nada, dibuja de memoria el ciclo de las cinco piezas con las flechas en la dirección correcta y anota junto a cada una la garantía que aporta.
  2. Escribe las cuatro cláusulas del invariante con tus palabras y ponles al lado los cuatro operadores de composición que las hacen cumplibles a escala.
  3. Abre la feature más compleja que hayas escrito y busca una violación de cada cláusula: un dato que la vista sabe y el estado no, un evento sin caso, lógica fuera del reducer, mundo sin dependencia.
  4. Corrige la violación más barata de las cuatro y comprueba que un test que antes no podías escribir ahora se escribe en diez líneas.
  5. Explícale el ciclo a alguien que no conozca TCA en menos de tres minutos, sin nombrar ni una sola API. Si no puedes, aún no has comprimido: has memorizado.