wandres.dev
PATRONES AVANZADOS · undo, optimista, entity

Colecciones normalizadas a fondo: adaptadores, orden y selectores generados

El nivel cinco presentó createEntityAdapter como la abstracción que captura el patrón de colección normalizada. Esta lección lo lleva hasta el detalle que decide el comportamiento en producción: la semántica exacta de las ocho operaciones de escritura y por qué elegir mal entre set y upsert corrompe datos, el coste real de mantener el array de identificadores ordenado y cuándo el orden no debe vivir en el adaptador, la memoización que traen los selectores generados y la trampa de crearlos dentro de un componente, y el modelado de varias vistas ordenadas de la misma colección sin duplicar entidades ni sincronizar nada a mano.

⏱ 19 min

Una colección normalizada es un mapa de entidades indexado por identificador más una lista de identificadores que impone el orden. La estructura es tan simple que parece no admitir profundidad, y sin embargo casi todos los problemas de rendimiento y de corrupción de datos que aparecen en aplicaciones grandes viven exactamente ahí: en la diferencia entre reemplazar y fusionar, en el comparador de orden que se ejecuta más veces de las que crees, en el selector que devuelve un array nuevo cada vez, y en las dos mitades de la estructura desincronizándose porque alguien escribió en una y no en la otra. createEntityAdapter existe para hacer imposibles esos errores, pero solo protege a quien conoce la semántica de lo que está llamando. Esta lección recorre esa semántica con el detalle que la primera aproximación no podía permitirse.

🎯 Al terminar esta lección sabrás
  • Distinguir la semántica exacta de las ocho operaciones de escritura del adaptador y sus modos de fallo.
  • Evaluar el coste de mantener el orden en el estado y decidir cuándo ordenar en el selector.
  • Usar los selectores generados con su memoización intacta, evitando la creación por render.
  • Modelar varias vistas ordenadas o filtradas de una misma colección sin duplicar entidades.

Las ocho escrituras y su semántica exacta

El adaptador expone cuatro familias de escritura y dentro de cada una la variante singular y la plural. La distinción crítica no está entre singular y plural sino entre las tres formas de introducir datos, que se confunden constantemente. addOne inserta solo si el identificador no existe y no hace nada si ya estaba, lo que la vuelve segura para eventos duplicados y silenciosa cuando esperabas una actualización. setOne escribe siempre, reemplazando la entidad entera si existía, de modo que cualquier campo que no venga en el objeto nuevo desaparece. upsertOne inserta si es nueva y fusiona superficialmente si existía, conservando los campos ausentes.

Elegir mal entre setOne y upsertOne produce el bug de colección más habitual y más difícil de atribuir. Imagina una entidad enriquecida con campos locales —una marca de pendiente, un contador de intentos, un fragmento cargado aparte— y una respuesta parcial del servidor que solo trae los campos canónicos. Con upsertOne los campos locales sobreviven; con setOne desaparecen sin error, sin advertencia y sin correlación temporal con el código que los escribió. El síntoma llega minutos después, en otra pantalla, y el sospechoso natural es siempre el componente que los muestra.

const adaptador = createEntityAdapter<Tarea>({
  selectId: (t) => t.uuid,
  sortComparer: (a, b) => a.titulo.localeCompare(b.titulo),
})

const slice = createSlice({
  name: 'tareas',
  initialState: adaptador.getInitialState({ estado: 'inactivo' as const }),
  reducers: {
    // Sincronizacion con el servidor: fusiona, no destruye campos locales.
    sincronizadas: adaptador.upsertMany,
    // Reemplazo total de la coleccion: descarta lo que ya no viene.
    reemplazadas: adaptador.setAll,
    // Cambio parcial: el payload es { id, changes }, no la entidad entera.
    editada: adaptador.updateOne,
    borrada: adaptador.removeOne,
  },
})

updateOne y updateMany merecen su propia advertencia porque su carga útil no es una entidad sino el par identificador y cambios, y esa fusión también es superficial: pasar un objeto anidado dentro de los cambios reemplaza el objeto entero, no fusiona sus campos. Además, si el identificador no existe, la operación se ignora en silencio, comportamiento razonable para un mapa pero traicionero cuando el identificador venía de una entidad que otra operación acababa de borrar. Y setAll es la más destructiva de todas: sustituye la colección completa, así que aplicarla a una respuesta paginada elimina todas las páginas anteriores.

add

Inserta si no existe, ignora si existe. Segura frente a duplicados, inútil si esperabas refrescar.

📥

set

Escribe siempre y reemplaza la entidad entera. Pierde cualquier campo que no venga en el objeto nuevo.

🔁

upsert

Inserta o fusiona superficialmente. Es la operación correcta para sincronizar con el servidor sin destruir estado local.

✏️

update

Cambio parcial con identificador y cambios. Fusión superficial y silenciosa si el identificador no existe.

El orden: quién lo mantiene y cuánto cuesta

Con sortComparer definido, el array de identificadores permanece ordenado en todo momento y ningún componente necesita ordenar nada. La comodidad es real y el coste también: cada inserción obliga a colocar el elemento en su sitio, y las operaciones plurales reordenan el bloque afectado, de modo que sincronizar mil entidades ejecuta el comparador un número de veces proporcional a mil por su logaritmo. Si el comparador es barato, es irrelevante; si compara cadenas con reglas de idioma, la diferencia entre construir una instancia de comparación una vez y construirla dentro del comparador es de un orden de magnitud.

La pregunta de fondo no es el coste sino la ubicación conceptual del orden. El orden que pertenece al estado es el intrínseco de la entidad —cronológico, por posición explícita, el orden canónico que el servidor define—. El orden que pertenece a la vista es el que el usuario elige pulsando una cabecera de tabla, y ese no debe vivir en el adaptador por una razón estructural: si la vista puede cambiar el criterio, meterlo en el estado obliga a reordenar la colección entera en cada cambio de preferencia, y si hay dos vistas simultáneas con criterios distintos, el modelo directamente no puede representarlas.

// Comparador caro construido una vez, no por comparacion.
const colacion = new Intl.Collator('es', { sensitivity: 'base', numeric: true })
const adaptador = createEntityAdapter<Tarea>({
  sortComparer: (a, b) => colacion.compare(a.titulo, b.titulo),
})
💡
Orden intrínseco en el estado, orden de vista en el selector

Deja en sortComparer únicamente el orden que sería el mismo para cualquier usuario y cualquier pantalla, y resuelve el resto con selectores memoizados que ordenan a partir de selectAll y del criterio elegido. Esos selectores solo recomputan cuando cambia la colección o el criterio, así que el coste se paga una vez por cambio real y no una vez por escritura. Y como cada vista tiene su selector, varias tablas con ordenaciones distintas sobre los mismos datos dejan de ser un problema de modelado para convertirse en dos funciones puras que leen la misma fuente.

Los selectores generados y la memoización que se pierde

getSelectors devuelve el juego completo re-basado sobre la rama del store que le indiques, y no son funciones ingenuas: selectAll está construido con memoización sobre los identificadores y las entidades, de modo que devuelve la misma referencia mientras la colección no cambie. Esa estabilidad referencial es justamente lo que impide que cada componente suscrito se vuelva a renderizar en cada acción, y es la propiedad que se destruye con más facilidad. La forma canónica de destruirla es llamar a getSelectors dentro del cuerpo de un componente o dentro de otra función que se ejecute por render: cada llamada construye selectores nuevos con cachés vacías, y la memoización deja de existir sin que nada falle de forma visible.

// Correcto: se construyen una vez, en el modulo.
export const {
  selectAll: seleccionarTareas,
  selectById: seleccionarTareaPorId,
  selectIds: seleccionarIdsTareas,
  selectTotal: seleccionarNumTareas,
} = adaptador.getSelectors((s: RootState) => s.tareas)

// Vista ordenada por criterio del usuario: memoizada y sin duplicar entidades.
export const seleccionarTareasOrdenadas = createSelector(
  [seleccionarTareas, (_: RootState, criterio: Criterio) => criterio],
  (tareas, criterio) => [...tareas].sort(comparadorPara(criterio)),
)

Conviene también recordar qué devuelve cada selector cuando no hay nada, porque es la fuente de un fallo tipado que se cuela con facilidad. selectById devuelve la entidad o nada en absoluto, y su tipo lo refleja, de modo que cualquier lectura directa de un campo sobre su resultado es un error que el compilador señala. La tentación de silenciarlo con una aserción es enorme y siempre acaba mal, porque el caso en que la entidad falta es real: una fila que sigue montada un instante después de que su entidad fuese borrada, un identificador que llega de la dirección y no existe, una instancia optimista revertida. La lectura correcta contempla la ausencia y decide qué mostrar, y esa decisión es parte del diseño de la pantalla, no un detalle de tipos.

Hay una asimetría que conviene interiorizar: selectIds y selectById son mucho más baratos que selectAll, porque el primero devuelve un array que ya existe en el estado y el segundo una búsqueda directa en el mapa. Un componente de lista que se suscribe a selectIds y delega en hijos suscritos a selectById con su identificador solo se vuelve a renderizar cuando cambia la composición o el orden de la lista, mientras que editar el título de un elemento repinta exclusivamente a ese hijo. Suscribir el componente padre a selectAll produce el comportamiento contrario: cualquier cambio en cualquier entidad repinta la lista entera.

flowchart TD
S[rama del store] --> I[selectIds]
S --> E[mapa de entidades]
I --> L[lista se suscribe a los ids]
L --> H[cada fila se suscribe por su id]
E --> H
I --> A[selectAll reconstruye el array]
A --> V[vista ordenada o filtrada memoizada]
style H fill:#a6e3a1,color:#11111b
style A fill:#f9e2af,color:#11111b

Varias vistas de una sola colección

El error de modelado más caro con colecciones es duplicarlas. Aparece una pantalla que necesita las tareas filtradas por proyecto, alguien crea una segunda colección con el resultado, y a partir de ese instante existen dos copias de la misma entidad que hay que mantener sincronizadas en cada escritura. La regla que evita el problema entero es que las entidades viven en un solo sitio y todo lo demás son listas de identificadores o funciones que las derivan. Una vista es un array de identificadores, un criterio de orden y un predicado de filtro; nunca un array de entidades almacenado.

Hay un caso intermedio que merece nombre propio porque es donde casi todos los equipos resbalan: la paginación. La tentación es guardar cada página como un array de entidades, y el resultado es que la entidad número doce existe en la página uno y en el resultado de una búsqueda, con dos copias que se editan por separado. La forma correcta es que las entidades de cualquier respuesta entren en la colección con upsertMany y que la página guarde únicamente su lista de identificadores y su cursor. Con eso, editar un elemento se refleja en todas las páginas y en todos los resultados de búsqueda a la vez, sin que nadie escriba una línea para sincronizarlos.

// Una unica coleccion de entidades; las vistas son listas de ids.
interface EstadoTareas extends EntityState<Tarea, string> {
  paginas: Record<number, string[]>
  resultadosBusqueda: string[]
  seleccionada: string | null
}

La misma regla resuelve las relaciones entre colecciones distintas. Un proyecto no contiene sus tareas: guarda los identificadores de sus tareas, o ni siquiera eso si cada tarea guarda el identificador de su proyecto y un selector memoizado agrupa por él. Anidar la entidad completa vuelve a crear la duplicación que la normalización existía para evitar, con el agravante de que ahora la copia vive en otra colección y ninguna operación del adaptador de tareas la va a tocar.

ℹ️
Campos que no son entidades dentro del mismo estado

getInitialState acepta campos adicionales junto a los identificadores y las entidades, y es el sitio correcto para el estado de carga, el error de la última petición, el cursor de paginación o el identificador seleccionado. Guardar ahí el elemento seleccionado como identificador y no como objeto es lo que mantiene una sola fuente de verdad: si el objeto seleccionado fuese una copia, editar la entidad dejaría la selección mostrando datos viejos. La misma lógica se aplica a cualquier referencia entre entidades, que debe ser siempre un identificador y nunca un objeto anidado.

El adaptador no te ahorra código: te impone un esquema, y esa imposición es la que hace escalable a una aplicación

Vale la pena leer createEntityAdapter como algo distinto de una utilidad de conveniencia, porque su valor real no está en las líneas que evita escribir. Lo que hace es introducir en la capa de estado del cliente una separación que las bases de datos relacionales resolvieron hace medio siglo y que las interfaces reinventan mal cada década: la separación entre el dato y sus vistas. La entidad existe una vez, identificada por una clave, y todo lo que una pantalla necesita —este orden, aquel filtro, esta selección, aquella agrupación— se expresa como una consulta sobre esa única copia en vez de como una copia más. Cuando un equipo no tiene esa separación, no aparece un problema grande sino una erosión: cada pantalla nueva añade su propia colección derivada, cada escritura tiene que acordarse de todas las copias existentes, y el número de sitios que hay que tocar para cambiar un campo crece con el producto hasta que nadie se atreve a tocarlo. Ese es el modo real en que muere el estado de una aplicación grande, y no ocurre por ignorancia de nadie: ocurre porque duplicar es siempre la opción localmente más rápida, y la factura la paga alguien distinto meses después. La virtud del adaptador es que invierte esa economía. Hace que la ruta de menor resistencia —usar los selectores generados, guardar identificadores, fusionar con upsert— sea también la ruta correcta, de modo que un desarrollador que no haya oído hablar jamás de las formas normales acabe produciendo estado normalizado por pura comodidad. Ahí está el criterio para juzgar cualquier abstracción que adoptes en la capa de estado, y es más exigente de lo que parece: no basta con que permita hacer lo correcto, porque eso lo permite cualquier cosa; tiene que hacer que lo correcto cueste menos que lo incorrecto, incluso el viernes por la tarde, incluso para quien no entiende por qué. Una abstracción que solo funciona cuando todo el equipo recuerda la disciplina no es una abstracción: es documentación con sintaxis.

⚔️ Audita y endurece una colección real
  1. Localiza en tu aplicación toda escritura sobre una colección y clasifícala como add, set, upsert o update; para cada setOne o setAll justifica por qué la destrucción de campos ausentes es aceptable ahí.
  2. Añade un campo puramente local a una entidad, sincroniza con el servidor usando primero setMany y después upsertMany, y observa la diferencia sin mirar el código.
  3. Mide cuántas veces se ejecuta tu sortComparer al sincronizar mil entidades e instrumenta el comparador con un contador.
  4. Traslada un orden que dependa de una elección del usuario desde sortComparer hasta un selector memoizado con criterio como argumento, y comprueba que dos vistas simultáneas con criterios distintos ya son posibles.
  5. Reescribe un componente de lista para que el padre se suscriba a selectIds y cada fila a selectById; cuenta renders al editar un solo elemento antes y después.
  6. Busca cualquier colección derivada almacenada en tu store, sustitúyela por una lista de identificadores o un selector, y enumera las escrituras que dejan de tener que acordarse de ella.