wandres.dev
@PRESENTS · sheets, alerts, popovers

`@Presents` y `PresentationAction`: el hueco y su canal

Presentar una pantalla en TCA no es dar una orden al sistema de vistas sino asignar un valor a un hueco del estado. Esta lección diseca las dos mitades de ese mecanismo: `@Presents`, que convierte un opcional corriente en un hueco de presentación con identidad y ciclo de vida propios, y `PresentationAction`, el canal de un solo caso que transporta a la vez todo lo que el hijo tiene que decir y el hecho de que el hijo se ha ido. Verás qué añade exactamente la anotación sobre un `Optional` pelado, por qué `ifLet` es el pegamento que hace correr al hijo solo mientras existe, y cómo un único hueco tipado con un `@Reducer enum` vuelve aritméticamente imposible tener dos pantallas presentadas a la vez.

⏱ 18 min

Un opcional en el estado parece decir ya todo lo que hace falta para presentar una pantalla: si hay valor hay pantalla, si no hay valor no la hay. Casi. Lo que un opcional pelado no sabe es que su contenido tiene vida propia —un reducer corriendo, efectos en vuelo, una identidad que SwiftUI necesita para distinguir una presentación nueva de una mutación de la anterior— y que esa vida debe empezar y terminar exactamente en los dos instantes en que el valor cruza la frontera de lo nulo. @Presents es la anotación que convierte el opcional en un hueco de presentación con ese contrato incorporado. PresentationAction es su hermano al otro lado, en el mundo de las acciones: un solo caso en el enum del padre que transporta dos mensajes de naturaleza distinta, todo lo que el hijo diga mientras esté vivo y el hecho escueto de que ha dejado de estarlo.

🎯 Al terminar esta lección sabrás
  • Declarar estado presentado con @Presents y distinguir qué añade sobre un opcional corriente.
  • Modelar el canal de acciones con PresentationAction y sus dos casos, presented y dismiss.
  • Conectar hueco y canal mediante ifLet para que el reducer hijo corra solo mientras exista su estado.
  • Agrupar varios destinos en un @Reducer enum y razonar por qué un único hueco prohíbe presentaciones simultáneas.

El hueco: un opcional con ciclo de vida

La forma mínima de una presentación tiene tres piezas que se declaran juntas y no funcionan por separado: un campo opcional anotado, un caso de acción envuelto y un ifLet al final del body.

@Reducer
struct Inventario {
  @ObservableState
  struct State: Equatable {
    var articulos: IdentifiedArrayOf<Articulo> = []
    @Presents var detalle: Detalle.State?
  }

  enum Action {
    case articuloPulsado(Articulo.ID)
    case detalle(PresentationAction<Detalle.Action>)
  }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case let .articuloPulsado(id):
        guard let articulo = state.articulos[id: id] else { return .none }
        state.detalle = Detalle.State(articulo: articulo)
        return .none

      case .detalle:
        return .none
      }
    }
    .ifLet(\.$detalle, action: \.detalle) {
      Detalle()
    }
  }
}

Presentar el detalle es una asignación; descartarlo será poner nil. Todo lo demás es consecuencia. La pregunta interesante es qué aporta la anotación, porque el código de arriba compilaría casi igual con un opcional sin anotar y luego fallaría de formas difíciles de diagnosticar. @Presents es un envoltorio de propiedad que guarda, junto al valor, un identificador de presentación: un token que cambia cada vez que el hueco pasa de nulo a no nulo. Ese token es lo que permite a TCA responder tres preguntas que el Optional no puede responder. Primera, si el estado que tengo delante es la misma presentación de hace un segundo o una nueva que sustituyó a la anterior, distinción indispensable para que la vista anime en vez de reemplazar de golpe. Segunda, a qué presentación pertenece cada efecto en vuelo, para poder cancelarlos todos al descartar y solo esos. Y tercera, hacia dónde debe viajar la petición de cierre cuando el hijo dice que ha terminado, asunto que ocupa la cuarta lección de este nivel.

El valor proyectado —lo que se escribe \.$detalle— es lo que expone ese envoltorio completo, y por eso los combinadores lo piden con el dólar mientras que la vista, que solo quiere el valor, usará el key path corriente. Es la misma distinción entre valor y envoltorio que ya conoces de @Binding, aplicada aquí a la navegación.

El canal: dos mensajes en un solo caso

Del lado de las acciones no hay dos casos sino uno, case detalle(PresentationAction<Detalle.Action>), y ese único caso multiplexa dos clases de mensaje muy distintas.

Caso del canal Quién lo emite Qué hace ifLet con él
.presented(accion) el hijo, desde su store rebanado lo entrega al reducer hijo si su estado existe
.dismiss SwiftUI | el padre | la dependencia dismiss pone el hueco a nil y cancela los efectos del hijo

La asimetría es deliberada. Las acciones envueltas en presented son el vocabulario entero de la feature hija viajando hacia arriba, y el padre puede observarlas —aunque, por lo que aprendiste sobre acciones delegate, solo debería mirar la parte publicada—. El caso dismiss, en cambio, no pertenece al hijo: es un mensaje sobre el hijo, emitido por quien sea que constate su desaparición, y su semántica es siempre la misma con independencia de la feature.

case let .detalle(.presented(.delegate(.articuloGuardado(articulo)))):
  state.articulos[id: articulo.id] = articulo
  return .none

case .detalle(.dismiss):
  return .none                      // ifLet ya ha puesto el hueco a nil

case .detalle:
  return .none                      // el interior del hijo no es asunto del padre

En los tests y en cualquier sitio donde uses rutas de caso, PresentationAction se atraviesa sin nombrarlo: \.detalle.delegate.articuloGuardado funciona porque la ruta salta el presented automáticamente. La ceremonia está en el tipo, no en el uso.

💡
No pongas el hueco a nil al recibir `.dismiss`

Escribir case .detalle(.dismiss): state.detalle = nil es redundante y engañoso: para cuando tu reducer ve esa acción, ifLet ya ha vaciado el hueco y cancelado los efectos del hijo. Lo que sí tiene sentido en esa rama es reaccionar al cierre —refrescar una lista, guardar un borrador, registrar un evento—, nunca repetir la mutación. Si te descubres asignando nil ahí, sospecha que has entendido dismiss como una petición cuando en realidad es la notificación de un hecho ya consumado.

Un solo hueco para varios destinos

Cuando una pantalla puede presentar tres cosas distintas, la tentación es declarar tres opcionales anotados. Es exactamente el error que el bloque de modelado de dominio enseñó a detectar: tres opcionales admiten ocho combinaciones y solo cuatro son legales. Un @Reducer enum de destino colapsa esas ocho en cuatro por construcción.

@Reducer
struct Inventario {
  @Reducer
  enum Destino {
    case detalle(Detalle)
    case editar(Editor)
    case ajustes(Ajustes)
  }

  @ObservableState
  struct State: Equatable {
    var articulos: IdentifiedArrayOf<Articulo> = []
    @Presents var destino: Destino.State?
  }

  enum Action {
    case articuloPulsado(Articulo.ID)
    case ajustesPulsado
    case destino(PresentationAction<Destino.Action>)
  }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case let .articuloPulsado(id):
        guard let articulo = state.articulos[id: id] else { return .none }
        state.destino = .detalle(Detalle.State(articulo: articulo))
        return .none

      case .ajustesPulsado:
        state.destino = .ajustes(Ajustes.State())
        return .none

      case .destino:
        return .none
      }
    }
    .ifLet(\.$destino, action: \.destino)
  }
}

Repara en el ifLet sin closure final: la macro @Reducer aplicada a un enum sintetiza el estado suma, la acción suma y un reducer que despacha cada caso a su feature, de modo que no hay nada que pasarle. Y repara en la consecuencia semántica de tener un único hueco: cambiar de destino es reasignar el mismo campo, así que abrir los ajustes desde el detalle cierra el detalle sin que nadie escriba una línea de cierre. La exclusión mutua entre pantallas dejó de ser una invariante que mantener a mano y pasó a ser una propiedad del tipo.

flowchart LR
N[destino igual a nil] -->|asignar un caso| P[destino presentado]
P -->|PresentationAction presented| R[reducer hijo dentro de ifLet]
R --> P
P -->|PresentationAction dismiss| N
P -->|el padre asigna nil| N
N -->|ifLet cancela efectos del hijo| N
style P fill:#89b4fa,color:#11111b
style N fill:#a6e3a1,color:#11111b
La presentación es un intervalo de existencia, y por eso hacía falta algo más que un opcional

Conviene entender por qué la biblioteca no se conformó con Optional y añadió una anotación, porque la respuesta explica de golpe casi todo el nivel. Un opcional describe un valor que puede faltar, y eso es una afirmación puramente estática: en este instante hay algo o no lo hay. Una presentación, en cambio, no es un valor que está sino un intervalo que dura. Tiene un principio —el instante en que el usuario pulsa y el hueco se llena—, un final —el instante en que se vacía— y, entre medias, una feature entera viviendo su vida: un reducer que corre, efectos suscritos a relojes y sockets, tareas asíncronas capturando dependencias. La diferencia entre las dos nociones es la misma que entre un punto y un segmento, y esa diferencia se paga con un dato adicional, el identificador de presentación, que es lo que permite decir cuándo dos valores pertenecen al mismo segmento y cuándo empezó uno nuevo. Sin él, sustituir el detalle del artículo A por el del artículo B es indistinguible de mutar el detalle existente, y esa ambigüedad se manifiesta como animaciones que no ocurren, efectos del artículo A que siguen enviando acciones al estado del artículo B, y pantallas que se niegan a cerrarse porque el sistema cree que nunca se abrieron. Lo notable es que TCA resuelve todo eso sin salirse ni un milímetro de su tesis: no introduce un coordinador, ni un router, ni un objeto de navegación con métodos: introduce más datos. El ciclo de vida de una pantalla se vuelve una propiedad legible del estado, y las operaciones que en otras arquitecturas son irreversibles por naturaleza —presentar, descartar— se convierten en asignaciones sobre un valor que puedes imprimir, serializar, comparar y afirmar en un test. La navegación deja de ser algo que le pasa a la aplicación y pasa a ser algo que la aplicación es.

⚔️ Convierte un booleano de presentación en un hueco tipado
  1. Busca en tu app una pantalla que se presente con un Bool y un modelo suelto guardado aparte. Anota cuántos campos del estado colaboran hoy para decir qué hay en pantalla.
  2. Sustitúyelos por un único @Presents var destino: Destino.State? con un @Reducer enum que enumere todos los destinos posibles de esa pantalla, incluidos los que hoy se presentan con otros booleanos.
  3. Traduce cada acción de apertura a una asignación del caso correspondiente, y borra todo el código que hoy pone booleanos a false para evitar solapamientos.
  4. Añade el ifLet al final del body y comprueba que el reducer del hijo deja de correr cuando el hueco se vacía: mete un print en su rama de aparición y observa que no se repite.
  5. Cuenta las combinaciones de estado que eran representables antes y las que lo son ahora. La diferencia es la cantidad de bugs que acabas de volver inexpresables.