wandres.dev
RENDIMIENTO · evitar re-renders

Selectores granulares: el trozo mínimo y la comparación correcta

Si cada despacho pregunta a todos los suscriptores, la respuesta que cada componente da determina su frecuencia de render. Esta lección establece las dos variables que la gobiernan: la granularidad de lo que se selecciona y la función con la que se compara. Recorre la regla del trozo mínimo y por qué varios useSelector primitivos casi siempre baten a uno que devuelve un objeto, el papel exacto de shallowEqual en react-redux y de useShallow en Zustand, el coste de comparar frente al coste de renderizar, y el patrón de listas por identificadores en el que el padre selecciona ids estables y cada fila selecciona su propia entidad para localizar el render donde de verdad cambió el dato.

⏱ 17 min

La lección anterior dejó el problema reducido a una frase: el store pregunta a todos y la lentitud es el precio acumulado de tus respuestas. Toca ahora aprender a responder bien, y resulta que responder bien depende de solo dos decisiones. La primera es qué seleccionas: cuanto más estrecho es el trozo, menos veces cambia y menos veces renderizas. La segunda es cómo comparas: la identidad referencial es la comparación por defecto y la más barata, pero no siempre es la que corresponde a lo que tu selector devuelve. Casi todo el arte del rendimiento en un store global cabe en cruzar bien esas dos decisiones, y casi todos los desastres provienen de haber elegido una granularidad gruesa y luego intentar arreglarla con una comparación cada vez más cara.

🎯 Al terminar esta lección sabrás
  • Aplicar la regla del trozo mínimo y entender por qué reduce la frecuencia de render.
  • Justificar por qué varios useSelector primitivos suelen batir a uno que devuelve un objeto.
  • Usar shallowEqual en react-redux y useShallow en Zustand sabiendo qué comparan y qué no.
  • Aplicar el patrón de listas por identificadores para localizar el render en la fila que cambió.

La regla del trozo mínimo

Un selector define, sin decirlo, la condición bajo la cual su componente se re-renderiza: siempre que el valor que devuelve deje de ser idéntico al anterior. Escribir un selector es por tanto escribir un predicado de render, y conviene leerlos así. estado.usuario significa vuelve a renderizar cuando cualquier campo del usuario cambie; estado.usuario.nombre significa vuelve a renderizar solo cuando cambie el nombre. Ambos alimentan el mismo componente con el mismo texto en pantalla, pero el segundo se despierta muchas menos veces.

// Grueso: cualquier cambio en el objeto usuario dispara render.
const usuario = useSelector((s: RootState) => s.usuario)
return <span>{usuario.nombre}</span>

// Minimo: solo un cambio de nombre dispara render.
const nombre = useSelector((s: RootState) => s.usuario.nombre)
return <span>{nombre}</span>

El valor primitivo tiene además una virtud que los objetos no pueden tener: su identidad coincide con su contenido. Dos cadenas iguales son idénticas para ===, así que un selector que devuelve un primitivo es automáticamente estable, sin memoización ni comparadores. Esa es la razón profunda por la que la regla del trozo mínimo funciona tan bien: no solo reduce la superficie de cambio, sino que además elimina el problema de la identidad en su origen, en vez de gestionarlo después.

Cuando un componente necesita tres campos, la duda clásica es si escribir tres useSelector primitivos o uno solo que devuelva un objeto con los tres. La respuesta idiomática es tres, y no por estilo. Cada useSelector adicional cuesta un acceso a propiedad y una comparación de identidad por despacho, es decir, nada; mientras que el selector único que construye un objeto devuelve una referencia nueva en cada ejecución y renderiza en todos los despachos, salvo que lo rescates con un comparador. La versión que parecía más ordenada es la que introduce el problema.

💡
Cuántos hooks son demasiados

La objeción práctica llega cuando el componente necesita diez campos y la cabecera se llena de hooks. Antes de resolverlo con un objeto y un comparador, sospecha del componente: diez lecturas del store suelen indicar que ese componente hace demasiado y debería repartirse en piezas que lean cada una lo suyo. Si aun así el objeto agrupado es la forma correcta, entonces sí, agrupa y compara superficialmente. La jerarquía de decisiones importa: primero divide el componente, después reduce la granularidad, y solo al final introduce un comparador más caro.

Comparar bien: shallowEqual y useShallow

Hay casos donde devolver una estructura es inevitable y correcto. Un componente que necesita cinco campos relacionados y los pasa juntos hacia abajo, o una llamada que debe devolver una tupla, produce por fuerza una referencia nueva en cada ejecución. Para eso existe la comparación superficial: en lugar de preguntar si la referencia es la misma, recorre las claves de primer nivel y pregunta si cada valor es idéntico al de antes.

import { shallowEqual, useSelector } from 'react-redux'
import { useShallow } from 'zustand/react/shallow'

// react-redux: segundo argumento con la funcion de igualdad.
const { nombre, rol } = useSelector(
  (s: RootState) => ({ nombre: s.usuario.nombre, rol: s.usuario.rol }),
  shallowEqual,
)

// Zustand v5: useShallow envuelve al selector, no es un segundo argumento.
const { nombre: n2, rol: r2 } = useSesion(
  useShallow((s) => ({ nombre: s.usuario.nombre, rol: s.usuario.rol })),
)

Las dos formas expresan la misma idea con ergonomías distintas, y la diferencia de firma es histórica: Zustand retiró en su versión 5 el segundo argumento de igualdad y lo sustituyó por un envoltorio del selector, useShallow, que memoiza el resultado en lugar de comparar en el sitio. El efecto observable es el mismo —el componente no renderiza si todas las claves de primer nivel siguen siendo idénticas— pero conviene saber que uno compara resultados sucesivos y el otro devuelve el resultado anterior cuando la comparación superficial acierta.

⚠️
Superficial significa un nivel, y solo uno

El error que anula esta técnica es aplicar shallowEqual a algo cuyo primer nivel ya contiene referencias nuevas. Un array producido con map que construye objetos frescos falla la comparación superficial en su primer elemento, porque cada elemento es un objeto distinto aunque su contenido sea igual. Lo mismo ocurre con un objeto cuyas claves apuntan a subobjetos recién creados. La comparación superficial solo salva el caso concreto de un envoltorio nuevo alrededor de valores viejos: contenedor fresco, contenido estable. Fuera de ese caso no te ayuda, y encima añade el coste de recorrer las claves en cada despacho para llegar siempre a la misma conclusión negativa.

Ese coste merece una mirada explícita, porque la comparación no es gratis. Comparar superficialmente cuesta un recorrido lineal en el número de claves, ejecutado en cada despacho de la aplicación entera y multiplicado por cada componente que lo use. Comparar por identidad cuesta una sola instrucción. La diferencia es despreciable para cinco claves y deja de serlo para estructuras anchas leídas por muchos componentes en escenarios de escritura frecuente. La regla que se deduce es de nuevo la misma: la comparación cara es la última defensa, no la primera; su misión es rescatar una granularidad que no pudiste afinar más, no sustituir el trabajo de afinarla.

flowchart TD
Q[que devuelve tu selector] --> P[un primitivo]
Q --> O[un objeto envoltorio con valores estables]
Q --> D[una estructura derivada nueva]
P --> I[comparar por identidad: gratis y correcto]
O --> S[comparar superficialmente: coste lineal en claves]
D --> M[memoizar el calculo: leccion 4]
style I fill:#a6e3a1,color:#11111b
style S fill:#f9e2af,color:#11111b
style M fill:#fab387,color:#11111b

Listas: seleccionar identificadores, no entidades

El caso donde la granularidad decide de verdad el rendimiento es la lista larga. La versión ingenua selecciona el array de entidades completo en el componente padre y lo recorre pintando filas. Con esa forma, tocar un solo campo de un solo elemento produce un array nuevo, el padre renderiza, y todas las filas se reconcilian aunque solo una haya cambiado. El coste de una edición puntual acaba siendo proporcional al tamaño de la lista.

El patrón correcto invierte quién lee qué. El padre selecciona únicamente los identificadores, que forman un array de primitivos y solo cambia cuando se añade, se borra o se reordena algo. Cada fila recibe su identificador como propiedad y selecciona por su cuenta la entidad correspondiente. Ahora una edición cambia la entidad de una fila, esa fila renderiza y ninguna otra se entera: el coste de la edición dejó de depender del tamaño de la lista.

// El padre lee ids: un array de primitivos, estable ante ediciones.
function Lista() {
  const ids = useSelector((s: RootState) => s.tareas.ids, shallowEqual)
  return <ul>{ids.map((id) => <Fila key={id} id={id} />)}</ul>
}

// Cada fila lee su propia entidad: solo renderiza la que cambio.
function Fila({ id }: { id: string }) {
  const tarea = useSelector((s: RootState) => s.tareas.entidades[id])
  return <li>{tarea.titulo}</li>
}

Hay un segundo efecto, menos citado y a veces más importante que el primero: la reconciliación del padre desaparece del camino crítico. Cuando el padre no renderiza, React no vuelve a recorrer la lista, no reevalúa las claves y no compara mil elementos para descubrir que novecientos noventa y nueve siguen igual. El trabajo evitado no es solo el de las filas, sino el del algoritmo que las coordina, que crece con el tamaño de la colección aunque cada fila sea trivial.

Este patrón explica retroactivamente por qué la normalización del estado importaba tanto en el nivel de los selectores. Un estado normalizado —un diccionario de entidades por identificador más un array de identificadores— no es una preferencia de modelado, sino precisamente la forma que permite leer con granularidad fina: da un array estable para la estructura y un acceso directo y estable para cada elemento. La normalización es, vista desde este nivel, una decisión de rendimiento disfrazada de decisión de modelado.

Cuándo la granularidad fina deja de pagar

Toda técnica tiene un régimen fuera del cual se vuelve contraproducente, y la granularidad no es excepción. Cada suscripción adicional es un selector más que se ejecuta en cada despacho de la aplicación entera. Partir un componente en veinte lecturas primitivas cambia un render potencial por veinte comparaciones garantizadas, y aunque ese cambio casi siempre sale a cuenta, deja de hacerlo cuando el componente renderiza igualmente por otra causa, cuando el número de suscripciones simultáneas se cuenta por decenas de miles o cuando la frecuencia de despacho es muy alta.

Paga casi siempre

Componentes que leen pocos campos de ramas que cambian a ritmos distintos, y listas donde cada fila lee su propia entidad. Aquí lo fino gana por goleada.

⚖️

Es indiferente

Componentes que renderizan de todos modos porque su padre lo hace, o cuyos datos cambian siempre juntos. Afinar el selector no evita nada; el render llega por otra puerta.

🛑

Empieza a doler

Miles de filas conectadas a la vez con selectores caros. La respuesta no es un selector mejor, sino virtualizar y dejar de tener miles de suscriptores vivos.

🔁

El caso límite

Escritura de altísima frecuencia, como un arrastre o una animación. Ese dato no debería viajar por el store global, por muy fino que sea el selector que lo lee.

El caso de las listas enormes ilustra bien el límite. El patrón de identificadores localiza el render en la fila que cambió, pero no reduce el número de suscriptores: diez mil filas montadas son diez mil selectores ejecutados por despacho. La solución correcta a partir de cierto volumen no está en la capa de estado sino en la de presentación: si solo hay veinte filas visibles, virtualizar deja veinte suscriptores vivos y el problema desaparece por completo. Reconocer cuándo un problema de rendimiento ha dejado de pertenecer a tu capa es parte del criterio.

💡
Pasar hacia abajo también es una opción

Existe una alternativa que la costumbre de conectar todo hace olvidar: no leer del store en el hijo y recibir el dato como propiedad. Un padre conectado que pasa valores primitivos a hijos memoizados obtiene un resultado parecido al de muchas suscripciones finas, con menos oyentes registrados y un flujo de datos más fácil de seguir. La regla práctica que funciona es conectar donde el dato cambia con independencia y pasar hacia abajo cuando cambia solidariamente con lo que ya tiene el padre.

Un selector no lee datos: declara la condición bajo la cual tu componente vuelve a existir

La costumbre de llamar lectura a lo que hace un selector oculta su verdadera naturaleza y con ella la razón de que la granularidad importe tanto. Un selector no es un accesor: es una función que, ejecutada tras cada cambio del sistema, produce el valor cuya identidad decide si un fragmento de interfaz debe reconstruirse. Escribirlo es firmar un contrato de reactividad, y ese contrato tiene un ámbito medible —el conjunto de cambios del estado que atraviesan el filtro y llegan a producir un render—. Seleccionar el objeto usuario firma un contrato ancho: cualquier campo, aunque no lo pintes nunca, es un motivo válido para reconstruirte. Seleccionar el nombre firma un contrato estrecho que coincide con lo que realmente dibujas. La expresión que se acuñó para esto, granularidad, es exacta pero engaña por su aire descriptivo, porque no describe un tamaño de dato sino un tamaño de causa: cuántos sucesos distintos del mundo tienen permiso para tocarte. Y como en un store global el número de sucesos es enorme y no lo controlas, lo único que puedes gobernar es la anchura de tu permiso. De ahí que la disciplina correcta no consista en optimizar los selectores lentos, sino en estrechar los selectores permisivos, y que casi siempre resulte más eficaz partir un componente en dos —cada uno con su causa propia— que refinar el comparador de uno que escuchaba demasiado. Comparar mejor mitiga; seleccionar más fino elimina. Y cuando la granularidad ya no puede estrecharse más porque el dato que necesitas de verdad se construye al vuelo, el problema deja de ser de anchura y pasa a ser de identidad, que es exactamente el territorio de la lección siguiente.

⚔️ Estrecha el contrato de tus componentes
  1. Elige tres componentes conectados y reescribe sus selectores para que devuelvan primitivos; anota cuántos hooks resultan y si el componente se lee mejor o peor.
  2. Encuentra un selector que devuelva un objeto agrupado y decide, con criterio explícito, si conviene partirlo, dividir el componente o añadir shallowEqual.
  3. Convierte una lista que selecciona entidades completas al patrón de identificadores y comprueba que editar un elemento ya no renderiza los demás.
  4. Aplica useShallow en un store de Zustand que devuelva varias claves a la vez y verifica el cambio en la frecuencia de render.
  5. Construye un caso donde shallowEqual no sirva de nada —un array de objetos derivados— y explica en una frase por qué falla.
  6. Formula para cada selector que hayas tocado la frase qué cambios del estado tienen permiso para renderizarme, y compárala con lo que el componente dibuja de verdad.