En la parte 1 construimos un buscador aislado: una feature con estado, efectos cancelables, una dependencia inyectada y un test. Funciona, pero no es una app. Una app es la pantalla de búsqueda que abre un detalle, que abre un editor, que a veces muestra una hoja modal y otras un stack de navegación de tres niveles.
Y aquí aparece un problema conocido: la navegación tiende a terminar viviendo en la vista: un @State private var showingSheet = false por aquí, un NavigationLink por allá, y de pronto no puedes reproducir un estado de la app sin tocar la pantalla con el dedo.
En TCA la navegación también es estado. Y si está en el estado, se puede componer, serializar, restaurar y testear.
Vamos a extenderlo. Nuestro buscador ahora abre un detalle del repositorio, y ese detalle puede abrir una hoja para editar notas.
Composición: una feature dentro de otra
El caso más simple: una feature siempre contiene a otra. Piensa en una pantalla de ajustes que incluye una sección de cuenta.
@Reducer
struct AjustesFeature {
@ObservableState
struct State: Equatable {
var cuenta = CuentaFeature.State()
var modoOscuro = false
}
enum Action {
case cuenta(CuentaFeature.Action)
case modoOscuroCambiado(Bool)
}
var body: some ReducerOf<Self> {
Scope(state: \.cuenta, action: \.cuenta) {
CuentaFeature()
}
Reduce { state, action in
switch action {
case .cuenta:
return .none
case let .modoOscuroCambiado(activo):
state.modoOscuro = activo
return .none
}
}
}
}
Scope conecta un pedazo del estado del padre con el reducer del hijo. A partir de ahí las acciones del hijo llegan envueltas en .cuenta(...), y el padre puede reaccionar a ellas si le interesa.
Este segundo punto es el que menos se aprovecha, y a mí es el que más me ha servido. El padre puede escuchar acciones del hijo para coordinar:
case .cuenta(.sesionCerrada):
state.modoOscuro = false
return .run { send in
await limpiarCache()
}
El hijo no sabe que el padre existe; el padre decide qué significa que el hijo haya cerrado sesión. Es el mismo problema que normalmente resuelves con un delegado o con NotificationCenter, pero sin objetos intermedios que registrar y dar de baja.
En la vista, scope hace lo equivalente:
struct AjustesView: View {
@Bindable var store: StoreOf<AjustesFeature>
var body: some View {
Form {
CuentaView(store: store.scope(state: \.cuenta, action: \.cuenta))
Toggle("Modo oscuro", isOn: $store.modoOscuro.sending(\.modoOscuroCambiado))
}
}
}
Listas: forEach sobre estado identificado
Nuestro buscador devuelve una lista. Si cada fila tuviera su propia lógica (un botón de favorito con su propia llamada a red, por ejemplo), cada fila necesita su propia feature.
Para eso existe IdentifiedArrayOf y el operador forEach:
@ObservableState
struct State: Equatable {
var consulta = ""
var filas: IdentifiedArrayOf<FilaFeature.State> = []
}
enum Action {
case consultaCambiada(String)
case filas(IdentifiedActionOf<FilaFeature>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
// ...
}
.forEach(\.filas, action: \.filas) {
FilaFeature()
}
}
¿Por qué IdentifiedArrayOf y no [FilaFeature.State]? Porque con un array normal las acciones viajarían por índice, y los índices cambian. Si la fila 3 dispara un efecto y mientras tanto se elimina la fila 1, ese efecto terminaría aplicándose a otra fila. Con IDs eso no pasa: la acción va dirigida a un elemento concreto, y si ese elemento ya no existe, la acción simplemente se descarta.
Es un bug clásico de listas asíncronas, y aquí lo previene el tipo de dato.
Navegación como estado: @Presents
Ahora sí, la parte que cambia la forma de pensar. Nuestro detalle se presenta desde la búsqueda, y desde el detalle se abre una hoja de edición.
Primero declaramos todos los destinos posibles como un enum de reducers:
@Reducer
enum Destino {
case detalle(DetalleFeature)
case editor(EditorFeature)
}
Esa macro sobre un enum genera el State y el Action correspondientes, cada caso envolviendo los del hijo. En el padre:
@ObservableState
struct State: Equatable {
var consulta = ""
var resultados: [Repositorio] = []
@Presents var destino: Destino.State?
}
enum Action {
case consultaCambiada(String)
case resultadosRecibidos([Repositorio])
case repositorioTocado(Repositorio)
case destino(PresentationAction<Destino.Action>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .repositorioTocado(repo):
state.destino = .detalle(DetalleFeature.State(repo: repo))
return .none
// ...
}
}
.ifLet(\.$destino, action: \.destino)
}
Fíjate en lo que acaba de pasar: presentar una pantalla es asignar un valor a una propiedad. Se acabaron los isPresented y las banderas booleanas que se desincronizan entre sí.
Y como destino es un enum opcional, el compilador te garantiza que no puedes tener dos pantallas presentadas al mismo tiempo por accidente. Con tres @State private var showingX sueltos ese estado inválido sí es representable, y tarde o temprano alguien lo produce.
La vista:
struct BuscadorView: View {
@Bindable var store: StoreOf<BuscadorFeature>
var body: some View {
List(store.resultados) { repo in
Button(repo.nombre) { store.send(.repositorioTocado(repo)) }
}
.navigationDestination(
item: $store.scope(\.destino, action: \.destino).detalle
) { store in
DetalleView(store: store)
}
.sheet(
item: $store.scope(\.destino, action: \.destino).editor
) { store in
EditorView(store: store)
}
}
}
Un mismo destino alimenta un push y una hoja modal, según el caso del enum. La API es la misma para sheet, popover, navigationDestination y alert, que es una de las cosas que más se agradecen en un proyecto grande: aprendes un patrón y te sirve para los cuatro casos.
El ifLet no es decoración
.ifLet(\.$destino, action: \.destino) hace tres cosas que a mano se olvidan:
- Ejecuta el reducer hijo solo cuando hay algo presentado.
- Cancela automáticamente todos los efectos del hijo cuando se descarta la pantalla. Sin esto, una petición de red iniciada en el detalle seguiría viva después de cerrarlo.
- Le da al hijo la capacidad de descartarse solo con
@Dependency(\.dismiss), sin conocer al padre.
El punto 2 es la clase de fuga que en una app hecha a mano descubres tres meses después, cuando algo escribe en un estado que ya no debería existir. Me ha pasado, y encontrarlo no fue divertido.
Stacks profundos: StackState
@Presents sirve para presentaciones puntuales. Para un NavigationStack con profundidad arbitraria (detalle → detalle → detalle) el modelo es otro, aunque muy parecido:
@Reducer
enum Ruta {
case detalle(DetalleFeature)
case editor(EditorFeature)
}
@ObservableState
struct State: Equatable {
var ruta = StackState<Ruta.State>()
}
enum Action {
case ruta(StackActionOf<Ruta>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
// ...
}
.forEach(\.ruta, action: \.ruta)
}
Y la vista:
NavigationStack(path: $store.scope(\.ruta, action: \.ruta)) {
BuscadorView(store: store)
} destination: { store in
switch store.case {
case let .detalle(store): DetalleView(store: store)
case let .editor(store): EditorView(store: store)
}
}
Empujar una pantalla desde el reducer es state.ruta.append(.detalle(...)). Volver dos niveles es quitar dos elementos del stack. El estado de navegación completo es un valor que puedes imprimir, guardar y comparar.
¿Cuándo usar cada uno? Si la pantalla se presenta sobre la actual y al cerrarse te devuelve exactamente a donde estabas, @Presents. Si el usuario puede seguir hundiéndose en niveles, StackState.
Testear un flujo de navegación
Aquí está el pago. Un flujo que en una app normal solo se puede verificar tocando la pantalla, aquí es un test:
@Test
func abrirDetalleYCerrarlo() async {
let repo = Repositorio(id: 1, nombre: "swift", descripcion: "El lenguaje")
let store = await TestStore(
initialState: BuscadorFeature.State(resultados: [repo])
) {
BuscadorFeature()
}
await store.send(.repositorioTocado(repo)) {
$0.destino = .detalle(DetalleFeature.State(repo: repo))
}
await store.send(.destino(.dismiss)) {
$0.destino = nil
}
}
No hay simulador, no hay XCUITest, no hay esperas frágiles. Es una función que corre en milisegundos y verifica que tocar una fila abre exactamente el detalle correcto.
Y si el hijo dispara un efecto, el TestStore exige que lo verifiques también, así que no puedes cerrar la pantalla y dejar trabajo pendiente sin que el test te lo diga.
Dónde duele
Sería deshonesto no decirlo: la composición en TCA tiene un costo concreto.
- Los tipos crecen:
IdentifiedActionOf<FilaFeature>,PresentationAction<Destino.Action>,StackActionOf<Ruta>. Cuando algo no compila, el mensaje de error puede ser de veinte líneas. - Hay más piezas que conectar. Un
Scopeolvidado hace que el hijo simplemente no reaccione, sin ningún error, y la primera vez cuesta encontrarlo. - Los tiempos de compilación suben. Cada macro genera código, y en módulos con muchas features se nota.
A cambio: el estado de navegación de toda la app cabe en un valor, no puede quedar inconsistente, y se testea sin abrir el simulador. En una app de dos pantallas eso no compensa. En una de veinte, con deep links y restauración de estado, cambia el juego.
Qué sigue
Ya sabemos construir features y componerlas. Falta el problema que aparece cuando la app crece de verdad: el estado que necesitan varias features a la vez, como el usuario autenticado, los favoritos o los ajustes. Duplicarlo lleva a inconsistencias, y pasarlo por parámetro a través de cinco niveles es insufrible.
En la parte 3 vemos @Shared, persistencia, y cómo TCA maneja el estado global sin volverse un singleton disfrazado. Después llega el testing a fondo y la comparativa contra MVVM.
Fuentes: Tree-based navigation (docs) · Stack-based navigation (docs)