En la vista: un store propio para cada fila
La colección ya está en el estado y el reducer hijo ya corre por elemento; falta que SwiftUI vea cien vistas independientes en vez de una tabla de datos. El puente es `store.scope` aplicado a la colección, que produce un store por fila: en TCA moderno se recorre directamente con `ForEach`, y en el estilo previo con `ForEachStore`. Esta lección muestra ambas formas, explica por qué la vista de fila recibe un `StoreOf` y nada más, y por qué esa rebanada individual es lo que hace que la fila se pueda previsualizar sola, animar bien y navegar por su cuenta.
Hay una tentación casi irresistible al llegar a la vista: como el padre tiene la colección entera, iterar sobre los valores y pasar a cada celda su modelo y una closure para avisar de los toques. Funciona, y es exactamente lo que rompe todo lo construido en las dos lecciones anteriores, porque devuelve la fila al mundo de los callbacks anónimos donde nadie sabe quién habló. La alternativa de TCA es tratar la vista como trata al reducer: si el reducer hijo se obtiene multiplicando uno por la colección, la vista hija se obtiene rebanando el store del padre en un store por elemento. Cada fila recibe entonces un StoreOf propio, cerrado sobre su identidad, y vuelve a ser lo que era en el reducer: una feature completa que ignora por completo que tiene noventa y nueve hermanas.
- Producir un store por elemento con
store.scopesobre el estado y la acción de la colección. - Recorrer esos stores con
ForEachen TCA observado y reconocer el estilo previo conForEachStore. - Escribir vistas de fila que reciban únicamente su
StoreOfy sigan siendo previsualizables en aislamiento. - Enlazar la fila con navegación y gestos de lista sin romper la identidad que SwiftUI necesita para animar.
Rebanar el store de la colección
scope es la misma operación que ya conoces para un hijo único, con una diferencia de tipo: cuando el key path de estado apunta a una colección identificada y el de acción a una acción dirigida, el resultado no es un store sino una colección de stores, uno por elemento, cada uno identificado por el id de su elemento.
struct ListaView: View {
let store: StoreOf<Lista>
var body: some View {
List {
ForEach(store.scope(state: \.filas, action: \.filas)) { filaStore in
FilaView(store: filaStore)
}
}
}
}
struct FilaView: View {
let store: StoreOf<Fila>
var body: some View {
HStack {
Text(store.texto)
Spacer()
Text("\(store.segundos)s").monospacedDigit()
}
.onAppear { store.send(.alAparecer) }
}
}
FilaView no recibe un índice, ni un modelo suelto, ni una closure de callback: recibe un store y con eso le basta para leer su estado y enviar sus acciones. Ese store está preconfigurado para envolver cada acción con la identidad correcta, así que store.send(.alAparecer) desde la tercera fila llega al reducer padre como .filas(.element(id: idDeLaTercera, action: .alAparecer)) sin que la vista escriba una palabra sobre identidades. La simetría con el reducer es exacta: allí el aislamiento lo garantizaba forEach, aquí lo garantiza scope.
ForEachStore y su sucesor observado
Antes de la llegada de @ObservableState, recorrer una colección de features exigía una vista dedicada, ForEachStore, que se ocupaba de suscribirse solo a los cambios relevantes y de mantener la identidad de cada fila.
// Estilo previo, aún vigente en bases de código con WithViewStore
ForEachStore(store.scope(state: \.filas, action: \.filas)) { filaStore in
FilaView(store: filaStore)
}
Con la observación integrada, el scope de una colección devuelve algo que ya es una colección de acceso aleatorio cuyos elementos conforman Identifiable, y por tanto el ForEach corriente de SwiftUI la acepta sin adaptadores. Ese es el motivo por el que ForEachStore fue quedando atrás: no porque hiciera algo mal, sino porque su trabajo —observar con grano fino y preservar identidad— pasó a hacerlo el sistema. Conocer las dos formas importa igual, porque cualquier código escrito antes de la migración usará la primera y ambas conviven sin conflicto en el mismo proyecto.
Los enlaces bidireccionales viajan por el mismo camino. Una fila con un campo de texto no necesita nada especial del padre: declara su store como @Bindable y deriva el binding de su propio estado, con la acción que corresponda.
struct FilaView: View {
@Bindable var store: StoreOf<Fila>
var body: some View {
TextField("Título", text: $store.texto.sending(\.textoCambio))
}
}
Ese binding escribe mediante una acción de la fila, que el scope envuelve con la identidad y forEach entrega al elemento correcto. Es decir: incluso el gesto más aparentemente directo de SwiftUI —teclear en un campo— sigue recorriendo el ciclo unidireccional completo y queda registrado como una acción que un test puede afirmar. No hay puerta trasera, ni en una fila ni en diez mil.
Si escribes ForEach(store.filas) y luego pasas a la celda el valor de la fila, has perdido el store: la celda no puede enviar acciones dirigidas, y acabarás inventando closures que suben eventos al padre. Además fuerzas al padre a observar la colección completa, de modo que un cambio en cualquier fila reevalúa el cuerpo de la lista entera. La regla es literal: itera stores, no valores.
Qué gana la fila al tener store propio
El store por elemento no es una formalidad de plomería: compra cuatro propiedades concretas, y las cuatro se pierden a la vez en cuanto la celda vuelve a recibir un modelo y unas closures.
Aislamiento real
La vista de fila se previsualiza sola, con su propio Store y sin montar la lista, el padre ni las dependencias del resto de la app.
Observación de grano fino
Cada fila observa únicamente su rebanada, así que teclear en una no invalida el cuerpo de las demás. Es el tema de la lección siguiente.
Animación correcta
La identidad del store rebanado coincide con la del elemento, y con ella SwiftUI distingue un movimiento de un borrado seguido de un alta.
Navegación por fila
El destino de cada fila se enlaza al estado igual que en el Nivel 3, sin que la fila necesite saber quién la presenta.
La primera propiedad es la que conviene verificar de inmediato, porque es la que delata cualquier fuga de contexto: si la vista de fila se puede arrancar sola, es que de verdad no depende del padre.
#Preview("Una fila, sin lista y sin padre") {
FilaView(
store: Store(initialState: Fila.State(id: UUID(), texto: "Aislada")) {
Fila()
}
)
}
struct ListaView: View {
@Bindable var store: StoreOf<Lista>
var body: some View {
List {
ForEach(store.scope(state: \.filas, action: \.filas)) { filaStore in
FilaView(store: filaStore)
}
.onDelete { offsets in store.send(.borrar(offsets)) }
.onMove { origen, destino in store.send(.mover(origen, destino)) }
}
}
}
Repara en el reparto de responsabilidades del final. El toque sobre una fila es asunto de la fila y viaja por su store. Pero borrar y mover no son acciones de una fila: son operaciones sobre la colección, que solo el padre posee, y por eso se envían al store del padre con los índices que SwiftUI entrega. Esa frontera —lo que le pasa a un elemento contra lo que le pasa a la lista— es la que organiza la lección siguiente, y también la fuente del error más sutil del nivel, porque esos índices que llegan de onDelete hablan de lo que se ve en pantalla y no necesariamente de lo que hay en el estado.
flowchart TD SP[Store del padre Lista] --> SC[scope sobre filas y accion dirigida] SC --> S1[Store de la fila uno] SC --> S2[Store de la fila dos] SC --> S3[Store de la fila tres] S1 --> V1[FilaView aislada] S2 --> V2[FilaView aislada] S3 --> V3[FilaView aislada] V2 -->|send accion propia| SP style SC fill:#89b4fa,color:#11111b
Lo que scope entrega a cada fila no es un puntero a una parte del estado del padre: es un mundo completo y autosuficiente en el que esa fila es la única habitante. Dentro de ese mundo la feature lee su estado como si fuera todo el estado que existe y envía sus acciones como si nadie más las emitiera, y es precisamente esa miopía deliberada la que le da libertad de movimiento. Una vista que solo conoce su propio store puede colocarse en una lista, en una cuadrícula, en una hoja modal, sola en una preview o repetida diez mil veces, porque ninguna de esas circunstancias figura en su vocabulario. Compáralo con la solución que casi todo el mundo escribe primero, la celda que recibe el modelo y una closure para avisar de lo que pasa: allí la celda no habla de sí misma sino de su relación con quien la creó, y esa relación es contexto, y el contexto es lo que impide mover una pieza. La operación que hace posible el contraste es una vieja conocida de la programación funcional aplicada aquí a la interfaz: rebanar es proyectar un dominio grande sobre uno pequeño con una lente que sabe leer hacia abajo y escribir hacia arriba, de modo que la acción emitida en el dominio pequeño vuelve al grande enriquecida con la identidad que le faltaba. La vista nunca ve esa mecánica; solo ve su rebanada. Y esa es la forma que toma en la capa de presentación la misma asimetría que gobierna el reducer desde el Nivel 3: el padre conoce a sus hijos, ningún hijo conoce a su padre, y toda la composabilidad del sistema vive en esa flecha que nunca se invierte.
- Busca en tu código una lista donde la celda reciba un modelo y una o más closures de callback. Anota cuántos parámetros tiene su inicializador.
- Sustituye todos esos parámetros por uno solo, el
StoreOfde la fila, y traduce cada closure a un caso delActionde la fila. - En el padre, cambia la iteración sobre valores por
ForEachsobrestore.scope, y comprueba que ya no necesitas pasar el índice a ninguna parte. - Escribe una preview de la vista de fila que construya su propio
Storesin mencionar al padre. Si compila y corre, el aislamiento es real. - Mueve las acciones de borrar y mover al store del padre con
onDeleteyonMove, y anota qué índices te está entregando SwiftUI. Guarda la respuesta: la lección siguiente la necesita.