wandres.dev
COLECCIONES · forEach e IdentifiedArray

IdentifiedArray: la identidad antes que la posición

Un `Array` corriente direcciona por posición, y la posición es la magnitud más volátil de una lista viva: basta que alguien borre la primera fila para que todos los índices en vuelo apunten al elemento equivocado. Esta lección explica por qué TCA no guarda colecciones de features en un `Array` sino en `IdentifiedArrayOf`, qué garantiza esa estructura —acceso por id en tiempo constante, orden de inserción estable y unicidad de identidad— y por qué exigir `Identifiable` al estado hijo no es un trámite de la API sino el contrato que hace posible enrutar acciones tardías a un elemento concreto sin condiciones de carrera.

⏱ 18 min

Una lista de features no es una lista de valores: es una población de seres que nacen, cambian y mueren mientras el usuario mira. En una población así, el índice —el número de la posición— es la magnitud más volátil que existe: cambia cada vez que alguien borra una fila, reordena, o llega una página del servidor. Y sin embargo el índice es lo primero que uno usa para direccionar, porque un Array no ofrece otra cosa. TCA rompe ese hábito desde la primera línea: las colecciones de estado hijo no viven en un Array sino en IdentifiedArrayOf, una estructura que direcciona por identidad y no por posición. Esta lección sostiene que esa elección no es una comodidad de API sino una condición de corrección, y que Identifiable deja de ser un protocolo que se conforma por costumbre para convertirse en el contrato que sostiene el nivel entero.

🎯 Al terminar esta lección sabrás
  • Entender por qué el índice es una dirección insegura en cuanto los efectos son asíncronos y las acciones llegan tarde.
  • Conocer IdentifiedArrayOf y sus tres garantías: acceso por id en tiempo constante, orden de inserción estable y unicidad de identidad.
  • Elegir identificadores estables derivados del dominio y generarlos con @Dependency(\.uuid) para que los tests sigan siendo deterministas.
  • Distinguir la identidad de una feature de su contenido, y reconocer qué se rompe exactamente cuando ambos se confunden.

El índice miente, la identidad no

Considera una lista de filas donde cada una dispara una petición de red al aparecer. La acción de respuesta tiene que decir a qué fila pertenece el resultado. La tentación es llevar el índice, porque es el dato que tenías a mano cuando lanzaste el efecto.

// Frágil: la acción viaja con una posición
enum Action {
  case filaAparecio(Int)
  case respuestaLlegada(Int, String)
}

case let .respuestaLlegada(indice, texto):
  state.filas[indice].texto = texto  // ¿sigue siendo la misma fila?
  return .none

Entre el instante en que se lanzó el efecto y el instante en que llega su respuesta pasan cientos de milisegundos, y en ese hueco el usuario puede haber borrado la fila cero, haber arrastrado una fila de abajo arriba o haber recibido una recarga completa desde el servidor. El índice tres ya no señala a quien señalaba: en el mejor caso escribe el texto en la fila equivocada —un bug silencioso, invisible en el log, reproducible solo con el timing exacto—; en el peor, el array se ha encogido y el acceso revienta la app. Ninguna cantidad de cuidado arregla esto, porque el problema no está en el código sino en la naturaleza de la coordenada: un índice es una dirección relativa a un estado que ya caducó. La identidad, en cambio, viaja con el elemento: si la fila sigue viva, el id la encuentra donde sea que esté; si murió, el acceso devuelve nulo y la acción se disipa sin daño.

Qué garantiza IdentifiedArrayOf

IdentifiedArrayOf viene del paquete swift-identified-collections y es un híbrido deliberado entre array y diccionario: conserva el orden de inserción como un array y mantiene además un índice interno de id a posición, de modo que el acceso por identidad es de tiempo constante. Escribir IdentifiedArrayOf<Fila.State> es azúcar para IdentifiedArray<Fila.State.ID, Fila.State>, con el id extraído automáticamente de la conformidad a Identifiable.

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

// Direccionar por identidad, no por posición
state.filas[id: id]?.texto = texto

// Alta, baja y consulta
state.filas.append(Fila.State(id: uuid()))
state.filas.remove(id: id)
let sigueViva = state.filas[id: id] != nil

// Sigue siendo una colección ordenada e iterable
for fila in state.filas where !fila.completada { /* ... */ }

El subíndice por id devuelve un opcional, y ese opcional es la primera línea de defensa contra la acción tardía: escribir sobre un elemento que ya no existe no hace nada en vez de corromper a otro. La unicidad es la segunda garantía: append de un elemento cuyo id ya está presente no duplica la fila, y updateOrAppend te da el gesto de alta o actualización que necesita cualquier sincronización con un servidor. Y como sigue siendo una RandomAccessCollection, no pierdes nada de lo que un array te daba: iterar, mapear, contar y comparar con Equatable funcionan igual.

🔑

Acceso por id

El subíndice filas[id:] es de tiempo constante y devuelve un opcional. Direccionar a un elemento muerto es un no-op, no un crash.

📐

Orden estable

Mantiene el orden de inserción, así que la lista que ve el usuario es exactamente la secuencia guardada en el estado.

🚫

Unicidad de identidad

Dos elementos no pueden compartir id. El estado imposible de la fila duplicada queda prohibido por la estructura.

🧬

Valor Equatable

Es un tipo valor con copy-on-write y conforma Equatable cuando sus elementos lo hacen: encaja intacto en la disciplina del State.

Pregunta Array IdentifiedArrayOf
Cómo se direcciona un elemento por posición, que caduca al primer cambio por identidad, que sobrevive a todos
Coste de encontrar uno concreto lineal: hay que recorrer constante: índice interno de id a posición
Si el destinatario ya no existe índice fuera de rango o mutación ajena el subíndice devuelve nulo y no ocurre nada
Puede haber dos veces la misma entidad sí, y nadie lo impide no, la unicidad de identidad lo prohíbe
Sirve para forEach y scope no, no hay identidad que enrutar sí, es el tipo que ambos exigen

La última fila de la tabla es la que convierte a esta estructura en obligatoria y no en recomendable: los operadores de colección de TCA, tanto el del reducer como el de la vista, están tipados sobre colecciones identificadas. No es que trabajar con Array sea peor; es que directamente no compila.

La identidad es un requisito, no un adorno

Para que todo lo anterior se sostenga, el State del hijo debe conformar Identifiable con un id estable: fijado en el nacimiento del elemento y jamás recalculado a partir de su contenido mutable. Un id derivado del nombre, del texto o del hash de la estructura entera cambia cuando el usuario escribe una letra, y en ese instante la colección deja de ver una mutación para ver una baja seguida de un alta: SwiftUI destruye y recrea la fila, se pierde el foco del teclado, se cancelan los efectos en vuelo del elemento y la animación parpadea. El síntoma parece de interfaz; la causa es ontológica.

@Reducer
struct Fila {
  @ObservableState
  struct State: Equatable, Identifiable {
    let id: UUID          // se fija al nacer y no cambia jamás
    var texto: String = ""
    var completada = false
  }
  enum Action { case textoCambio(String) }
}

@Reducer
struct Lista {
  @Dependency(\.uuid) var uuid

  @ObservableState
  struct State: Equatable {
    var filas: IdentifiedArrayOf<Fila.State> = []
  }
  enum Action { case anadirTocado }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .anadirTocado:
        state.filas.append(Fila.State(id: self.uuid()))
        return .none
      }
    }
  }
}

El let en id no es estilo: es una prueba a nivel de compilador de que nadie podrá reasignarlo. Y fabricar el identificador con @Dependency(\.uuid) en vez de UUID() mantiene viva la lección del Nivel 2: en producción genera identidades reales, en el test se sustituye por un generador incremental y las aserciones sobre la colección se vuelven exactas en lugar de aproximadas.

flowchart TD
E[Efecto tardio emite una accion] --> IDX[Direccion por indice]
E --> IDN[Direccion por identidad]
IDX --> M[La lista cambio y el indice apunta a otra fila]
M --> X[Mutacion silenciosa en el elemento equivocado]
IDN --> Z[El subindice por id devuelve nulo]
Z --> Y[La accion se disipa sin dano]
style X fill:#f38ba8,color:#11111b
style Y fill:#a6e3a1,color:#11111b
💡
El id pertenece al dominio, no a la sesión

Si el elemento existe en un servidor, su id debe ser el del servidor, no uno que inventes al deserializar. Un id local hace que la misma entidad tenga identidades distintas en dos arranques de la app, y entonces la restauración de estado, la deduplicación al paginar y la reconciliación tras un refresco dejan de funcionar. La regla práctica: si dos valores representan la misma cosa del mundo, deben compartir id aunque su contenido difiera.

La identidad es lo que sobrevive al cambio, y por eso es la única dirección legítima

Detrás de la elección entre Array e IdentifiedArray hay una distinción vieja como la filosofía: la diferencia entre lo que una cosa es y dónde está. El índice describe una relación externa y contingente —esta fila está la tercera, hoy, hasta que alguien toque otra— mientras que el id nombra a la cosa misma, y por eso sobrevive a todo lo que le pase alrededor. Una arquitectura síncrona puede permitirse ignorar la diferencia, porque en ella nada cambia entre que lees una posición y la usas. TCA no puede, y no por un defecto sino por su virtud central: los efectos devuelven acciones más tarde, en un futuro donde el estado ya es otro, y una acción es literalmente un mensaje que llega desde el pasado a un mundo que no lo esperaba. Un mensaje así solo puede entregarse si trae la dirección de un destinatario que persiste, no la de un asiento que cualquiera pudo ocupar mientras tanto. De ahí que Identifiable no sea un requisito burocrático que la firma de forEach te impone, sino el mínimo metafísico que la asincronía exige: para hablar con algo que cambia, hay que poder nombrarlo con independencia de sus cambios. Todo el nivel se sigue de ahí. El enrutamiento por id de la lección siguiente, el store por fila de la tercera, la conservación del estado al reordenar de la cuarta y la deduplicación al paginar de la quinta son, cada una, un corolario de la misma decisión: que en una colección de features lo que se guarda no son posiciones ocupadas por datos, sino individuos con nombre propio.

⚔️ Audita las direcciones de tu lista
  1. Toma una pantalla tuya con una lista mutable y localiza cada sitio donde una acción, un efecto o un callback viaja con un Int de posición.
  2. Para cada uno, escribe el escenario concreto que lo rompe: qué tendría que borrar, mover o recargar el usuario mientras el efecto está en vuelo.
  3. Convierte el estado de las filas a IdentifiedArrayOf y cambia todas esas direcciones por el id del elemento. Comprueba que el compilador te obliga a tratar el opcional del subíndice.
  4. Revisa de dónde sale el id: si se calcula a partir de contenido mutable, sustitúyelo por uno fijado al nacer, y muévelo a @Dependency(\.uuid) si lo genera la app.
  5. Prueba el caso patológico: lanza un efecto lento desde una fila, borra esa fila antes de que responda y verifica que no pasa absolutamente nada. Esa nada es la garantía que acabas de comprar.