wandres.dev
COLECCIONES · forEach e IdentifiedArray

Añadir, borrar, mover y filtrar sin perder estado

Una colección de features viva se altera constantemente, y cada alteración amenaza con destruir lo que cada elemento llevaba dentro: su texto a medio escribir, su foco, su petición en vuelo. Esta lección recorre las cuatro operaciones cotidianas —alta, baja, reordenación y filtrado— sobre `IdentifiedArrayOf`, y muestra que la clave es siempre la misma: mutar en el sitio en vez de reconstruir, direccionar por id en vez de por posición, y entender que filtrar no es amputar la colección sino proyectarla, porque los elementos ocultos siguen vivos con todo su estado intacto.

⏱ 19 min

Hay una prueba brutal para saber si una lista está bien construida: escribe media frase en la tercera fila, sin salir del campo de texto activa un filtro que la oculte, quítalo, y mira si tu media frase y tu cursor siguen ahí. Casi ninguna lista pasa esa prueba, porque casi todas tratan la colección como un cubo de datos que se vacía y se rellena en cada cambio, y cada rellenado fabrica elementos nuevos que no recuerdan nada. En TCA los elementos no son datos sino features con estado propio y efectos en marcha, así que la pregunta deja de ser cómo mostrar la lista correcta y pasa a ser cómo alterar la población sin matar a nadie por accidente. Esta lección es el manual de esa cirugía: cuatro operaciones, un principio único —tocar lo mínimo, direccionar por identidad— y un cambio de mentalidad sobre lo que significa filtrar.

🎯 Al terminar esta lección sabrás
  • Dar de alta elementos con identidad fabricada por dependencia, distinguiendo append de updateOrAppend al sincronizar con un servidor.
  • Borrar por identidad y comprender por qué borrar es, además, cancelar todos los efectos de ese elemento.
  • Reordenar con move(fromOffsets:toOffset:) y aceptar que el orden de la lista es un dato del estado como cualquier otro.
  • Filtrar mediante una proyección calculada en vez de mutar la colección, y traducir los índices de la vista filtrada a identidades reales.

Alta: quién fabrica la identidad

Crear un elemento es una acción del padre, porque solo el padre ve la colección, y su único trabajo delicado es de dónde sale el identificador. Fabricarlo con UUID() directamente funciona en producción y arruina cualquier test, porque el valor es distinto en cada ejecución; fabricarlo con @Dependency(\.uuid) funciona igual en producción y se vuelve una secuencia predecible bajo prueba.

@Dependency(\.uuid) var uuid

case .anadirTocado:
  state.filas.append(Fila.State(id: self.uuid(), texto: ""))
  return .none

case let .llegoDelServidor(remotas):
  for remota in remotas {
    state.filas.updateOrAppend(Fila.State(remota))  // alta o actualización
  }
  return .none

Repara en quién ejecuta el alta: el padre, siempre, porque es el único que ve la colección. Una fila no puede crear hermanas igual que no puede borrarse a sí misma, y si necesita provocar un alta —duplicarse, por ejemplo— lo pide con una acción delegada y deja que el padre decida. Esa frontera se sostiene sin esfuerzo mientras el vocabulario de la fila no mencione la palabra lista.

La distinción entre los dos gestos es la que separa una lista local de una sincronizada. append respeta la unicidad y no hace nada si el id ya existe, lo cual es correcto para un alta genuina. updateOrAppend reemplaza el valor si la identidad ya está presente y lo añade al final si no, que es exactamente la semántica que necesita un refresco desde la red: los elementos que ya conocías se actualizan sin cambiar de sitio y los nuevos se incorporan al final, sin que la colección se reconstruya y sin que nadie pierda su estado.

Baja: borrar es también cancelar

Eliminar por identidad es una línea, y su efecto secundario es la garantía que viste en la lección anterior: forEach cancela los efectos en vuelo del elemento en cuanto desaparece de la colección. Borrar no es solo quitar un valor; es terminar un proceso.

case let .borrar(offsets):
  let visibles = state.filasVisibles
  for indice in offsets {
    state.filas.remove(id: visibles[indice].id)
  }
  return .none

Ese fragmento contiene el error más caro del nivel, ya corregido. Los offsets que entrega el onDelete de SwiftUI son índices sobre lo que hay renderizado, no sobre lo que hay guardado; si la lista muestra una proyección filtrada, borrar directamente por esos índices en la colección completa elimina elementos ajenos elegidos al azar. La traducción correcta es la del código: leer la proyección visible, sacar de ella la identidad del elemento señalado, y borrar por esa identidad en la colección real. Es el mismo principio de siempre, aplicado a la frontera entre vista y estado: el índice es una coordenada local que caduca al cruzar la frontera; el id no.

Mover: el orden es un dato

IdentifiedArrayOf conserva el orden de inserción precisamente para que el orden sea observable y mutable, y ofrece el gesto que SwiftUI espera del onMove.

case let .mover(origen, destino):
  state.filas.move(fromOffsets: origen, toOffset: destino)
  return .run { [filas = state.filas] _ in
    try await self.almacen.guardarOrden(filas.map(\.id))
  }

case .ordenarCompletadasAlFinal:
  state.filas.sort { !$0.completada && $1.completada }
  return .none

Mover no crea ni destruye elementos: permuta posiciones dentro de la misma estructura, así que ningún estado se pierde y ningún efecto se cancela. La fila que estaba contando segundos sigue contando mientras el usuario la arrastra. Y como el orden vive en el estado, persistirlo es serializar una secuencia de identidades, y restaurarlo es volver a ordenarlas: la disposición de la lista deja de ser una propiedad de la interfaz para ser un hecho del dominio, testeable como cualquier otro.

📝
La animación también es un dato de la acción

Cuando la reordenación la decide el reducer y no el dedo del usuario —un sort automático tras completar una tarea, por ejemplo—, la animación se pide al enviar la acción, con send(.ordenarCompletadasAlFinal, animation: .default), no envolviendo la mutación en un bloque animado dentro del reducer. El reducer sigue siendo puro y la vista sigue siendo un reflejo; lo único que viaja es la instrucción de cómo interpolar entre dos estados que ya estaban perfectamente definidos.

Filtrar: proyectar en vez de amputar

Aquí está el cambio de mentalidad. Filtrar no debe tocar la colección. Si al activar un filtro eliminas los elementos que no encajan, los estás matando: pierden su texto a medio escribir, su foco, sus efectos en vuelo, y al quitar el filtro renacen como recién nacidos que no recuerdan nada. La colección guarda la población entera; el filtro es una lente que se pone delante.

@ObservableState
struct State: Equatable {
  var filtro: Filtro = .todas
  var filas: IdentifiedArrayOf<Fila.State> = []

  var filasVisibles: IdentifiedArrayOf<Fila.State> {
    switch filtro {
    case .todas:       return filas
    case .pendientes:  return filas.filter { !$0.completada }
    }
  }
}

Y en la vista se rebana sobre la proyección, porque el scope de una colección admite un key path de solo lectura para el estado. El estado que se muestra es la proyección; las acciones siguen viajando a la colección real por identidad.

struct ListaView: View {
  @Bindable var store: StoreOf<Lista>

  var body: some View {
    List {
      ForEach(store.scope(state: \.filasVisibles, action: \.filas)) { filaStore in
        FilaView(store: filaStore)
      }
      .onDelete { store.send(.borrar($0)) }
    }
    .toolbar {
      Picker("Filtro", selection: $store.filtro.sending(\.filtroCambio)) {
        Text("Todas").tag(Filtro.todas)
        Text("Pendientes").tag(Filtro.pendientes)
      }
    }
  }
}

Cambiar el filtro es escribir un campo del estado del padre; la proyección se recalcula, scope produce los stores de lo visible y SwiftUI anima la entrada y salida de filas. Lo que no ocurre en ningún momento es una destrucción: los elementos ocultos siguen en filas, con su texto, su foco pendiente y sus efectos vivos, esperando a que la lente vuelva a incluirlos. Ocultar y mostrar dejan de ser operaciones destructivas para convertirse en lo que siempre debieron ser: dos maneras de mirar el mismo conjunto de seres.

Operación Gesto sobre la colección Qué conserva
Alta append o updateOrAppend El estado de los ya presentes, sin reconstruir
Baja remove(id:) tras traducir el índice visible Todo lo demás; cancela solo los efectos del borrado
Reordenar move(fromOffsets:toOffset:) o sort Absolutamente todo: solo permuta posiciones
Filtrar proyección calculada, filtro == .todas || !fila.completada El estado íntegro de lo oculto, que sigue vivo
flowchart LR
C[Coleccion real en el estado] --> F[Proyeccion filtrada calculada]
F --> V[Vista renderiza lo visible]
V -->|indices de onDelete| T[Traducir indice visible a id]
T --> C
C -->|move y sort permutan| C
style T fill:#f9e2af,color:#11111b
style C fill:#89b4fa,color:#11111b
⚠️
Reordenar con un filtro activo es ambiguo

Si la lista muestra una proyección, los índices de origen y destino del onMove hablan de la proyección, y traducirlos a posiciones de la colección completa no siempre tiene una única respuesta razonable: mover el segundo visible por delante del primero no dice nada sobre los elementos ocultos que había entre ambos. Muchas apps resuelven la ambigüedad prohibiendo la reordenación mientras hay filtro. Si necesitas permitirla, traduce ambos extremos a identidades y decide explícitamente qué pasa con lo oculto, en el reducer y con un test que lo fije.

Una lista no es una consulta que se rehace: es una población que se edita

El hábito que esta lección desmonta viene de una época en que la lista era la salida de una consulta: pedías los datos, construías las celdas, y cuando algo cambiaba volvías a pedir y a construir. En ese mundo, filtrar es lanzar otra consulta y ordenar es lanzar otra más, porque las celdas no contienen nada que valga la pena conservar; son papel. En cuanto cada elemento es una feature con estado propio y efectos vivos, ese hábito se vuelve destructivo, y no metafóricamente: rehacer la colección aniquila procesos en marcha y borra trabajo del usuario. La alternativa que TCA impone —mutar en el sitio, direccionar por identidad, proyectar en vez de amputar— es en realidad la misma disciplina que las bases de datos aprendieron hace medio siglo cuando separaron la tabla de la vista: los datos se editan con operaciones sobre filas identificadas, y las consultas no modifican nada, solo miran de otra manera. Que esa separación reaparezca aquí no es coincidencia sino consecuencia: en cuanto el estado es la fuente única de verdad y la interfaz un reflejo, la interfaz hereda el papel de la vista y el estado el de la tabla, con las mismas obligaciones. El filtro deja de ser un modo de la pantalla para ser una función pura del estado a un subconjunto; el orden deja de ser cómo quedaron las celdas para ser un dato persistible; el borrado deja de ser quitar una celda para ser terminar la vida de un elemento con todo lo que llevaba dentro. Y la prueba del principio, esa media frase que sobrevive al filtro, deja de ser un lujo de pulido para convertirse en lo que es: la señal de que has entendido que en la lista no hay datos, hay habitantes.

⚔️ Somete tu lista a la prueba de la media frase
  1. Monta una lista con filtro donde cada fila tenga un campo de texto y un efecto de larga vida, por ejemplo un contador por segundos.
  2. Escribe medio texto en una fila, activa un filtro que la oculte, quítalo y comprueba si el texto y el contador sobrevivieron. Si no lo hicieron, localiza dónde estás mutando la colección al filtrar.
  3. Convierte ese filtro en una propiedad calculada y rebana la vista sobre ella con un key path de solo lectura. Repite la prueba.
  4. Con el filtro activo, borra una fila mediante deslizamiento y verifica cuál desaparece. Si desaparece la equivocada, añade la traducción de índice visible a identidad.
  5. Escribe un test que fije las cuatro operaciones: alta con uuid controlado, baja por identidad bajo filtro, reordenación y filtrado. Que el test afirme, sobre todo, lo que no cambió.