wandres.dev
OPCIONALES Y ENUMS · ifLet e ifCaseLet

Estado opcional: una feature hija que existe o no existe

Entre el hijo que siempre está —dominio de `Scope`— y los muchos hijos de una colección —dominio de `forEach`— queda la cardinalidad intermedia y más cargada de significado: cero o uno. Esta lección estudia el estado hijo opcional como afirmación ontológica y no como dato ausente, y disecciona el operador `ifLet` en sus tres momentos: el nacimiento por asignación, la vida durante la cual las acciones se enrutan hacia abajo con el hijo corriendo antes que el padre, y la muerte al asignar `nil`, que arrastra consigo la cancelación de todos los efectos en vuelo. Distinguimos además el opcional puro del opcional presentado con `@Presents`, donde la desaparición puede venir tanto del reducer como del propio sistema de vistas.

⏱ 18 min

Un contador siempre existe; una lista contiene cero o muchos elementos. Entre esos dos extremos vive la cardinalidad que gobierna casi toda la navegación de una app real: cero o uno. La hoja de edición existe mientras el usuario la tiene abierta y no existe el resto del tiempo. El detalle del artículo existe mientras se está mirando. La alerta existe durante los tres segundos que tarda alguien en pulsar aceptar. Modelar esto con un booleano y un estado que permanece a la espera es la costumbre heredada, y es también el origen de una familia entera de fallos: datos rancios de la vez anterior, efectos que siguen corriendo tras cerrar la pantalla, banderas que se desincronizan. TCA lo modela como lo que es —un valor opcional— y le da un operador propio, ifLet, que no se limita a enrutar acciones: define un ciclo de vida completo con nacimiento, vida y muerte.

🎯 Al terminar esta lección sabrás
  • Leer el estado hijo opcional como ausencia de dominio y no como dato pendiente de rellenar.
  • Ensamblar ifLet sobre el reducer padre y justificar por qué el hijo corre siempre antes que el padre.
  • Recorrer los tres momentos del ciclo de vida: asignación, enrutado y anulación con cancelación automática.
  • Distinguir el opcional puro del opcional presentado con @Presents y su acción PresentationAction.

La cardinalidad cero-o-uno

En el Nivel 14 compusiste con Scope un hijo que está siempre presente, y en el Nivel 15 multiplicaste un reducer sobre una colección con forEach. Queda el caso intermedio, y la declaración es tan sencilla que engaña: un campo opcional.

@Reducer
struct Padre {
  @ObservableState
  struct State: Equatable {
    var titulo = ""
    var edicion: Edicion.State?
  }
  enum Action {
    case editarTocado
    case guardarTocado
    case edicion(Edicion.Action)
  }
}

Lo importante no es la interrogación, es lo que significa. Cuando edicion vale nil no hay un estado de edición vacío esperando turno: no hay estado de edición en absoluto. El dominio entero de esa feature hija —sus campos, sus invariantes, sus acciones posibles— deja de existir. Compáralo con el patrón habitual de una bandera mostrandoEdicion junto a un Edicion.State siempre residente: ahí el dominio del hijo persiste, con sus datos de la sesión anterior, y hay que acordarse de limpiarlo. Con el opcional, la limpieza es la desaparición misma; no se puede olvidar porque no es un paso.

Esto tiene una consecuencia sutil sobre las invariantes. El State del hijo puede exigir, por ejemplo, que su texto no esté vacío o que tenga un identificador válido, y esa exigencia solo debe cumplirse mientras el hijo vive. Al escribir nil no violas ninguna invariante del hijo: la retiras del universo de discurso. Modelar así es lo que permite que el tipo del hijo sea estricto sin que el padre tenga que fabricar valores de relleno para los ratos en que la pantalla no está abierta.

ifLet: nacer, vivir, morir

El operador se aplica como modificador sobre el reducer padre y toma el key path al opcional, el key path de caso a la acción del hijo y el reducer hijo.

var body: some ReducerOf<Self> {
  Reduce { state, action in
    switch action {
    case .editarTocado:
      state.edicion = Edicion.State(texto: state.titulo)
      return .none
    case .guardarTocado:
      state.titulo = state.edicion?.texto ?? state.titulo
      state.edicion = nil
      return .none
    case .edicion:
      return .none
    }
  }
  .ifLet(\.edicion, action: \.edicion) {
    Edicion()
  }
}

El nacimiento es una asignación y nada más. No hay una llamada de presentación, ni un coordinador que se entere, ni una vista a la que avisar: se escribe un valor donde había nil y con eso la feature hija empieza a existir. La vida es el enrutado: mientras el estado sea no nulo, toda acción .edicion alcanza al reducer del hijo, que corre antes que el Reduce del padre. Ese orden es la misma decisión que viste en forEach y por el mismo motivo: si el padre corriera primero podría anular el estado al procesar la acción, y el hijo jamás llegaría a reaccionar a su propio mensaje. La muerte es otra asignación, nil, y es el momento cargado de consecuencias que estudiarás a fondo en la cuarta lección de este nivel: todos los efectos que el hijo tuviera en vuelo se cancelan solos.

🌱

Nace por asignación

Escribir un valor donde había nil es todo lo que hace falta para que la feature hija empiece a existir.

🔁

Vive enrutando

Mientras el estado sea no nulo, el reducer hijo procesa sus acciones y siempre corre antes que el padre.

🪦

Muere con nil

Anular el estado retira el dominio del hijo y cancela de golpe todos sus efectos en vuelo.

🚨

Acción sin estado

Una acción del hijo que llega con el estado ya en nil no muta nada y avisa en tiempo de ejecución.

⚠️
La acción que llega tarde y el aviso que no debes silenciar

Si una acción .edicion entra cuando edicion ya vale nil, ifLet no tiene estado sobre el que operar: no muta nada y emite un aviso en tiempo de ejecución. La tentación de tratarlo como ruido es fuerte y casi siempre equivocada, porque ese aviso señala un desajuste real de diseño. Las causas típicas son tres: la vista sigue enviando acciones después de que el estado se descartara, el padre anula el estado al procesar la misma acción que el hijo necesitaba —recuerda que el hijo corre primero, así que esto solo pasa si lo anulas en una acción anterior—, o un efecto lanzado por el padre en nombre del hijo emite cuando el hijo ya no está. Ninguna de las tres se arregla ignorando el mensaje.

El opcional presentado: cuando la vista también puede matar

Hay una variante del opcional en la que la desaparición no siempre la decide el reducer. Cuando el hijo se muestra como hoja, alerta o pantalla empujada, el usuario puede descartarlo con un gesto, y esa noticia llega desde el sistema de vistas hacia dentro. Para ese caso TCA marca el opcional con @Presents y envuelve la acción en PresentationAction.

@ObservableState
struct State: Equatable {
  @Presents var edicion: Edicion.State?
}
enum Action {
  case editarTocado
  case edicion(PresentationAction<Edicion.Action>)
}

var body: some ReducerOf<Self> {
  Reduce { state, action in
    switch action {
    case .editarTocado:
      state.edicion = Edicion.State(texto: state.titulo)
      return .none
    case .edicion:
      return .none
    }
  }
  .ifLet(\.$edicion, action: \.edicion) {
    Edicion()
  }
}

PresentationAction unifica dos mensajes en un solo tipo: .presented(accionDelHijo) para todo lo que ocurre dentro del hijo, y .dismiss para la retirada. El padre puede escuchar ambos, y el propio hijo puede pedir su retirada sin conocer al padre usando la dependencia @Dependency(\.dismiss). La diferencia con el ifLet sin @Presents no es cosmética: la versión presentada garantiza que el estado y lo que hay en pantalla no puedan desincronizarse, porque el enlace es bidireccional y el descarte por gesto se traduce en una anulación del estado igual que si la hubieras escrito tú.

flowchart LR
N[Estado nil sin dominio hijo] -->|el padre asigna un valor| V[Estado no nil hijo vivo]
V -->|acciones enrutadas al reducer hijo| V
V -->|el padre asigna nil o llega dismiss| M[Muerte del hijo]
M -->|cancelacion automatica de efectos| N
style V fill:#a6e3a1,color:#11111b
style M fill:#f38ba8,color:#11111b

En el test, el ciclo de vida se afirma con la misma naturalidad con la que se escribe, y esa simetría es lo que convierte la navegación en algo comprobable.

await store.send(.editarTocado) {
  $0.edicion = Edicion.State(texto: "borrador")
}
await store.send(.edicion(.presented(.textoCambio("final")))) {
  $0.edicion?.texto = "final"
}
await store.send(.edicion(.dismiss)) {
  $0.edicion = nil
}
El opcional no es un dato que falta: es un dominio que no existe

Toda la potencia de este nivel cabe en esa distinción, y casi todo el sufrimiento de las arquitecturas que no la hacen cabe en su negación. Un booleano junto a un estado permanente dice el usuario no está viendo la edición ahora mismo, pero mantiene vivo todo el universo de la edición: sus campos con los valores de la vez anterior, sus invariantes que alguien tiene que sostener aunque nadie mire, sus efectos que nadie mandó parar. Un opcional en nil dice algo mucho más fuerte: la edición no existe. No hay texto rancio porque no hay texto; no hay invariante que sostener porque no hay valor; no hay efecto huérfano porque el operador que da vida al hijo es el mismo que se la quita. Es la diferencia entre apagar la luz de una habitación y demoler la habitación. Y el rendimiento conceptual de demolerla es que el conjunto de estados alcanzables de tu app deja de crecer multiplicativamente: donde antes tenías el producto cartesiano entre bandera y contenido del hijo —con toda la mitad ilegítima en la que la bandera es falsa pero el contenido sugiere otra cosa—, ahora tienes una suma, nil más los estados legítimos del hijo, sin ninguna combinación imposible que documentar, defender en revisiones o descubrir en producción. ifLet es el operador que hace que esa suma sea también una suma de comportamientos: un reducer que solo corre cuando su dominio existe, y unos efectos cuya duración es exactamente la duración de ese dominio. La navegación deja de ser una secuencia de órdenes y pasa a ser una propiedad de un valor, y con ella el ciclo de vida deja de estar en tu memoria para estar en el tipo.

⚔️ Da vida y quita vida a una feature
  1. Escribe una feature Edicion con un texto, una acción de cambio y un efecto largo —un guardado automático con reloj inyectado— que se lance al aparecer.
  2. Móntala en un padre como Edicion.State? con ifLet, y añade acciones para abrirla y cerrarla. Comprueba con un print que el guardado automático deja de emitir en cuanto asignas nil.
  3. Reescribe el padre con la versión ingenua: un booleano mostrandoEdicion y un Edicion.State siempre presente. Abre, escribe algo, cierra y vuelve a abrir. Observa el texto rancio y cuenta las líneas de limpieza que necesitas para evitarlo.
  4. Convierte el opcional a @Presents y preséntalo como hoja. Descártala con el gesto del sistema y afirma en un TestStore que llega .dismiss y que el estado queda en nil.
  5. Provoca a propósito el aviso de acción tardía enviando una acción del hijo con el estado ya anulado, y explica por escrito qué desajuste de diseño estaría señalando ese aviso en una app real.