Estructuras grandes: normalizar, virtualizar y no meter el mundo en un solo objeto
Cuando el estado pasa de decenas a decenas de miles de entradas, las técnicas que funcionaban dejan de hacerlo por razones estructurales: la copia del contenedor domina el perfil, el cono de invalidación abarca la colección entera y el DOM se llena de nodos que nadie mira. Esta lección establece las tres respuestas complementarias, normalizar por identificador para estrechar el cono, particionar el estado para que cada suscriptor dependa de lo mínimo, y virtualizar la vista para desacoplar el tamaño del DOM del tamaño de los datos.
Hay un umbral, en algún punto entre los cientos y los miles de elementos, donde el estado deja de comportarse como una variable y empieza a comportarse como una base de datos. Por debajo de ese umbral casi cualquier diseño funciona: copiar el array entero en cada cambio es imperceptible, suscribirse a la colección completa no molesta a nadie y pintar todas las filas es instantáneo. Por encima, los tres supuestos se rompen a la vez y lo hacen de forma no lineal, porque el coste de cada uno crece con el tamaño total de los datos mientras que el cambio que los provocó sigue siendo de un solo elemento. La solución no es un truco sino un cambio de forma: dejar de tratar la colección como un valor único y empezar a tratarla como un espacio indexado del que cada consumidor toma solo lo que necesita.
- Reconocer los tres síntomas que delatan que una estructura ha superado su umbral de escala.
- Normalizar una colección por identificador para estrechar el cono de invalidación.
- Particionar el estado de modo que ningún suscriptor dependa de la colección entera.
- Aplicar virtualización para desacoplar el tamaño del DOM del tamaño de los datos.
Tres costes que crecen con el total, no con el cambio
Un estado grande falla por tres vías distintas que conviene diagnosticar por separado porque tienen remedios diferentes. La primera es el coste de escritura: actualizar un campo de un elemento obliga a copiar el array contenedor, un trabajo proporcional al número de elementos aunque el cambio afecte a uno solo. La segunda es el coste de invalidación: si todos los consumidores se suscriben a la colección, cambiar cualquier elemento marca a todos como sucios, incluidos los que muestran elementos que no cambiaron. La tercera es el coste de salida: mantener en el DOM un nodo por elemento consume memoria, tiempo de cálculo de estilo y tiempo de composición, y lo hace incluso para las filas que están fuera de la pantalla.
// forma ingenua: un array plano como fuente unica
type Estado = { items: Item[] }
// cambiar un campo copia el array entero: proporcional a N
const siguiente = {
...estado,
items: estado.items.map(i => i.id === id ? { ...i, hecho: true } : i),
}
// ademas la referencia de items cambia, asi que TODO suscriptor a items se invalida
Los tres costes comparten una propiedad que los hace especialmente traicioneros: crecen con el tamaño total de los datos y no con la magnitud del cambio. Marcar una casilla en una lista de cincuenta elementos y marcarla en una de cincuenta mil son operaciones idénticas desde el punto de vista del usuario, pero la segunda cuesta mil veces más. Cualquier diseño en el que el coste de una interacción dependa de cuántos datos hay y no de cuántos datos toca la interacción está mal formado, por muy rápido que vaya con los datos de prueba.
Conviene además distinguir el volumen del ritmo, porque una colección grande y quieta no es un problema mientras que una mediana y muy caliente sí lo es. Diez mil filas que se cargan una vez y se leen durante minutos apenas cuestan nada después de la primera pintura. Mil filas que se actualizan sesenta veces por segundo, porque llegan por un canal en tiempo real, saturan cualquier diseño ingenuo. La dimensión que importa no es el número de elementos sino el producto del número de elementos por la frecuencia de escritura, y ese producto es el que decide si hacen falta las técnicas de este nivel.
Estos tres costes son invisibles con los conjuntos de datos que se usan al programar, que casi siempre tienen dos dígitos de elementos. Aparecen en producción, en el cliente que lleva tres años acumulando registros, y aparecen de golpe porque su crecimiento es lineal pero el umbral de percepción humana es abrupto: por debajo de dieciséis milisegundos nadie nota nada y por encima todo el mundo lo nota. Sembrar el entorno de desarrollo con volúmenes realistas es la única forma de que el diseño se enfrente a su régimen real antes de que lo haga un usuario.
Normalizar y particionar
La normalización sustituye la colección plana por un índice de entidades más una lista de identificadores. Es la misma decisión que toma cualquier base de datos relacional y produce los mismos beneficios: escribir toca un nodo pequeño y la identidad de las entidades no relacionadas se preserva.
type Estado = {
porId: Record<string, Item> // el indice de entidades
orden: string[] // la secuencia, separada del contenido
}
// cambiar un campo copia UN objeto y UN nivel del indice
const siguiente = {
...estado,
porId: { ...estado.porId, [id]: { ...estado.porId[id], hecho: true } },
}
// las demas entidades conservan su identidad: quien lea otra no se invalida
// y el array de orden no cambia, asi que quien solo pinta la secuencia tampoco
Separar el orden del contenido es la mitad menos evidente y la más rentable. Reordenar una lista pasa a ser una escritura sobre un array de cadenas, y editar un elemento pasa a no tocar el orden en absoluto, de modo que dos operaciones que antes competían por la misma referencia ahora son independientes. A partir de ahí, la partición consiste en que cada consumidor se suscriba a la unidad más pequeña que necesita: la fila que pinta un elemento se suscribe a esa entrada del índice, y el contenedor se suscribe solo al array de identificadores.
flowchart TD W[escritura sobre un elemento] --> N1[array plano invalida la coleccion entera] W --> N2[indice normalizado invalida una entrada] N1 --> C1[todos los suscriptores se recalculan] N2 --> C2[solo el suscriptor de esa entrada se recalcula] C1 --> V[la vista decide cuantos nodos existen] C2 --> V V --> V1[sin virtualizar hay un nodo por elemento] V --> V2[virtualizando hay un nodo por fila visible]
// suscripcion por entrada: el cono se estrecha solo
const orden = useStore(s => s.orden) // cambia al insertar o reordenar
const item = useStore(s => s.porId[id]) // cambia solo si ESA entrada cambia
// editar el elemento 7 no invalida ni el contenedor ni las otras 49 999 filas
La normalización tiene un coste propio que hay que asumir con honestidad. Derivar la lista ordenada y filtrada a partir del índice y del orden es un recorrido, y si ese derivado se recalcula en cada escritura se ha cambiado un problema por otro. La respuesta es que el derivado también debe particionarse: filtrar y ordenar sobre el array de identificadores, que solo contiene cadenas y solo cambia cuando cambia la composición de la lista, en lugar de sobre las entidades completas, que cambian cada vez que se edita un campo. Separar el orden del contenido no es únicamente una comodidad de escritura, es lo que permite que el derivado caro dependa de la parte del estado que rara vez se toca.
Casi todos los problemas de este nivel se remontan a la misma decisión inicial: haber puesto todo el dominio dentro de un único objeto de estado porque al principio resultaba cómodo. Ese objeto se convierte en un punto de contención donde cualquier escritura invalida cualquier lectura, y ninguna optimización posterior puede repararlo del todo porque el acoplamiento es estructural. Particionar por dominio en varios stores independientes, o al menos en ramas cuyas referencias evolucionen por separado, no es una cuestión de orden ni de estilo: es lo que permite que dos partes de la aplicación que no tienen nada que ver dejen de invalidarse mutuamente.
Virtualizar la vista
Normalizar y particionar arreglan el estado, pero no arreglan la salida. Aunque el cono de invalidación sea mínimo, si la aplicación crea un nodo del DOM por cada uno de los cincuenta mil elementos pagará memoria y tiempo de composición por todos ellos. La virtualización rompe ese vínculo manteniendo en el documento solo las filas que caben en la ventana visible más un pequeño margen, y traduciendo la posición del desplazamiento en un rango de índices.
// el nucleo de cualquier virtualizador
const alturaFila = 32
const primera = Math.floor(desplazamiento / alturaFila)
const cuantas = Math.ceil(alturaVisible / alturaFila) + margen
const visibles = orden.slice(primera, primera + cuantas)
// el contenedor declara la altura total para que la barra sea correcta
const alturaTotal = orden.length * alturaFila
Altura fija
El caso barato: la posición de cualquier fila se calcula con una multiplicación y el salto a un índice arbitrario es exacto e instantáneo.
Altura variable
Requiere medir y cachear alturas reales, y mantener una suma acumulada para localizar el índice. Un árbol de sumas parciales resuelve la consulta y la actualización de forma eficiente.
Reciclaje de nodos
Reutilizar los mismos elementos del DOM cambiando su contenido y su desplazamiento evita crear y destruir nodos en cada paso de la rueda del ratón.
Ventana con margen
Renderizar unas pocas filas por encima y por debajo de lo visible evita huecos en blanco durante desplazamientos rápidos, a cambio de un puñado de nodos extra.
Las tres técnicas de este nivel se ordenan por el eje que atacan y conviene aplicarlas en ese orden. Normalizar ataca el coste de escritura y es siempre correcto, incluso con pocos datos, porque no añade complejidad significativa. Particionar ataca el coste de invalidación y solo hace falta cuando hay varios consumidores independientes de la misma colección. Virtualizar ataca el coste de salida y es la única de las tres que impone un precio funcional real, así que es la última que se introduce y la primera que se retira si el volumen baja.
La virtualización tiene un coste que se paga en accesibilidad y en funcionalidad del navegador, y conviene asumirlo con los ojos abiertos. La búsqueda nativa de la página no encuentra lo que no está en el documento, los lectores de pantalla necesitan atributos explícitos para anunciar el tamaño real de la colección, y la selección de texto que abarca varias pantallas deja de funcionar. Ninguno de esos problemas es irresoluble, pero todos requieren trabajo adicional, así que la regla es virtualizar cuando el volumen lo exige y no antes. Como referencia práctica, por debajo de unos cientos de filas la virtualización suele costar más de lo que ahorra.
Hay además una interacción entre virtualización y estado que se descubre tarde y duele: el estado por fila. Si cada fila guarda si está expandida, en edición o seleccionada dentro de su propio componente, ese estado desaparece en cuanto la fila sale de la ventana y el nodo se recicla, y reaparece asignado a otra fila distinta si el reciclaje reutiliza el nodo sin limpiar. La regla es que en una lista virtualizada ningún dato que deba sobrevivir al desplazamiento puede vivir en el componente de la fila: debe estar en el estado indexado por identificador, junto al resto de la entidad, y la fila debe ser una función pura de esa entrada. Es el mismo principio de la partición, aplicado ahora a la efímera vida de los nodos visibles.
Antes de virtualizar cincuenta mil filas en el cliente, pregúntate por qué están las cincuenta mil en el cliente. La paginación por cursor, el desplazamiento infinito con ventana acotada o la búsqueda en el servidor eliminan el problema de raíz en lugar de gestionarlo, y además reducen el tiempo de la primera pintura y el consumo de memoria del dispositivo. La virtualización es la respuesta correcta cuando el usuario necesita de verdad recorrer el conjunto completo sin interrupciones, como en una hoja de cálculo o un visor de registros; en el resto de los casos suele ser una forma sofisticada de compensar una decisión de carga de datos equivocada.
// altura variable: suma acumulada para localizar el indice sin recorrer todo
const alturas = new Float64Array(orden.length) // medida real por fila
const prefijos = new Float64Array(orden.length + 1)
for (let i = 0; i < alturas.length; i++) prefijos[i + 1] = prefijos[i] + alturas[i]
// busqueda binaria sobre prefijos para hallar la primera fila visible
Con alturas variables, mantener las sumas acumuladas en un array plano vuelve la consulta logarítmica pero la actualización lineal, lo que basta si las alturas se miden una vez. Si cambian con frecuencia, por ejemplo porque las filas se expanden, hace falta un árbol de sumas parciales que ofrezca consulta y actualización logarítmicas a la vez. Es exactamente la misma estructura que usa un motor de bases de datos para mantener estadísticas de índice bajo escrituras continuas.
El punto donde una colección deja de caber cómodamente en el modelo mental de variable es el punto donde el desarrollador de interfaces se convierte, sin haberlo pedido, en administrador de datos. Y lo interesante es que todas las respuestas correctas ya estaban inventadas, porque son exactamente las mismas que las bases de datos llevan medio siglo puliendo, traducidas a otro vocabulario. Normalizar por identificador es la tercera forma normal, y persigue lo mismo, que un hecho viva en un solo sitio para que actualizarlo toque un solo lugar. Suscribirse por entrada en lugar de por colección es un índice, y hace lo mismo, convertir un escaneo completo en un acceso directo. Agrupar escrituras es una transacción. Virtualizar es paginar, con la ventana de desplazamiento haciendo de cursor. Separar el orden del contenido es distinguir un índice de la tabla que ordena. Ver esta correspondencia cambia la forma de razonar sobre el problema, porque deja de tratarse de trucos de rendimiento para pasar a ser un cuerpo de teoría con reglas conocidas y consecuencias predecibles. Y con la teoría llega también su principio rector, el que ninguna optimización local puede sustituir: el coste de una operación debe ser proporcional al tamaño de lo que la operación toca, nunca al tamaño de lo que existe. Cada vez que una interacción sobre un elemento cuesta en proporción al número total de elementos, hay una violación de ese principio en alguna parte del diseño, y el remedio no será un comparador más listo ni un memo mejor colocado, sino reorganizar la estructura hasta que el coste vuelva a ser proporcional al cambio.
- Construye una lista de cincuenta mil elementos como array plano y mide el tiempo de marcar una casilla; identifica qué fracción se va en copiar el contenedor.
- Normaliza el estado en un índice por identificador más un array de orden y repite la medición; comprueba que la copia ya no depende del total.
- Suscribe cada fila a su entrada del índice y verifica con un contador que cambiar un elemento solo recalcula un suscriptor.
- Añade virtualización de altura fija y compara el número de nodos del DOM, la memoria y el tiempo de la primera pintura contra la versión completa.
- Sustituye la altura fija por alturas variables medidas y describe qué estructura necesitas para localizar el índice a partir del desplazamiento sin recorrer todo.