wandres.dev
RENDIMIENTO · evitar re-renders

El selector que devuelve un objeto nuevo: el bug más común

Un selector que construye un objeto, un array o una tupla al vuelo devuelve una referencia distinta en cada ejecución, y con ello desactiva el único filtro que impedía que cada despacho de la aplicación entera renderizara el componente. Esta lección explica por qué el bug es invisible —nada falla, todo va lento—, por qué en la era de useSyncExternalStore puede degenerar en un bucle infinito de render, y desarrolla sus tres soluciones canónicas con el criterio para elegir entre ellas: descomponer en primitivos, comparar superficialmente o memoizar el cálculo. Incluye los avisos de modo desarrollo que convierten un problema silencioso en un mensaje explícito de consola.

⏱ 17 min

Hay un error de rendimiento que aparece en prácticamente todas las bases de código con estado global, que ningún test detecta, que ninguna herramienta de tipos previene y que nadie nota hasta que la aplicación crece lo suficiente para que se note todo. Consiste en escribir un selector que construye algo: un objeto con dos campos, un array filtrado, una tupla de valores. El código es correcto, legible y devuelve exactamente lo que promete. Su único defecto es que devuelve un objeto distinto cada vez que se ejecuta, y como el filtro que decide si un componente renderiza es una comparación de identidad, ese componente pasa a renderizar en cada despacho de la aplicación entera, incluso en los que no rozan sus datos. El bug no rompe nada: simplemente cancela la amortiguación y devuelve al componente al mundo del broadcast puro.

🎯 Al terminar esta lección sabrás
  • Reconocer las formas en que un selector devuelve una referencia nueva sin que se note.
  • Explicar por qué eso equivale a renderizar en cada despacho y cuándo degenera en bucle infinito.
  • Elegir con criterio entre las tres soluciones: descomponer, comparar superficialmente o memoizar.
  • Activar y leer los avisos de estabilidad e identidad que la librería emite en desarrollo.

Anatomía de una referencia nueva

El mecanismo es tan simple que resulta fácil pasarlo por alto. Cada vez que un literal de objeto o de array se evalúa, JavaScript asigna un objeto nuevo, con una identidad nueva, aunque su contenido sea idéntico al anterior. Un selector es una función que se ejecuta tras cada despacho; por tanto, un selector que contenga un literal, un map, un filter, un slice, un Object.values o un sort produce por construcción una identidad distinta en cada ejecución. El filtro posterior compara identidades y siempre concluye que algo cambió.

// Las cuatro formas cotidianas del mismo error.
useSelector((s: RootState) => ({ nombre: s.u.nombre, rol: s.u.rol }))     // literal de objeto
useSelector((s: RootState) => s.tareas.lista.filter((t) => !t.hecha))     // array derivado
useSelector((s: RootState) => [s.a.total, s.b.total])                     // tupla
useSelector((s: RootState) => Object.values(s.entidades))                 // proyeccion

// La version inofensiva: la identidad del valor coincide con su contenido.
useSelector((s: RootState) => s.u.nombre)

Merece la pena detenerse en un matiz que confunde a mucha gente: el problema no es que el selector devuelva un objeto, sino que lo construya. Devolver estado.usuario es devolver un objeto y es perfectamente estable, porque esa referencia solo cambia cuando el reducer la reemplaza. Lo que rompe la estabilidad es fabricar la estructura dentro del selector, porque entonces su identidad ya no la gobierna el reducer sino el momento de la llamada.

El error tiene además una versión gemela fuera de los selectores que conviene reconocer, porque es el mismo fenómeno con otro disfraz: el valor de contexto construido en el render del proveedor, o el objeto de propiedades fabricado al vuelo que se pasa a un hijo memoizado. En los tres casos el patrón es idéntico —una estructura recién creada cruzando una frontera que compara por identidad— y las soluciones son análogas. Quien entiende el problema en un selector lo reconoce ya en cualquier sitio.

La consecuencia práctica se enuncia mejor en términos de causa, siguiendo el vocabulario de la lección anterior. Un selector estable declara un contrato estrecho: renderízame cuando cambie esto. Un selector que construye declara el contrato más ancho posible: renderízame cuando ocurra cualquier cosa. Da igual que el dato que produce sea siempre el mismo; lo que el sistema observa es que la respuesta nunca coincide con la anterior.

⚠️
Del render inútil al bucle infinito

Antes de que react-redux delegara en useSyncExternalStore, este error solo costaba renders de más. Hoy puede costar bastante más caro, porque React exige que la instantánea que lee de una fuente externa sea estable: si cada llamada devuelve un valor distinto, el motor detecta que nunca converge y lanza el aviso de que el resultado debería cachearse para evitar un bucle infinito. En Zustand el mismo patrón —un selector que construye un objeto sin useShallow— produce exactamente el mismo síntoma. Que la advertencia sea ruidosa es una mejora: convirtió un problema silencioso de rendimiento en un fallo visible que hay que arreglar antes de seguir.

flowchart TD
A[cualquier despacho de la app] --> B[se ejecuta el selector]
B --> C[construye objeto o array nuevo]
C --> D[comparar con el resultado anterior]
D --> E[nunca identico]
E --> F[render garantizado]
F --> G[hijos reconciliados y efectos disparados]
style E fill:#f38ba8,color:#11111b
style F fill:#f38ba8,color:#11111b

Las tres soluciones y cuándo usar cada una

Las tres respuestas canónicas no son intercambiables: cada una ataca el problema en un punto distinto de la cadena y tiene un coste distinto. Descomponer elimina la construcción; comparar superficialmente tolera la construcción pero neutraliza su efecto; memoizar conserva la construcción pero le devuelve una identidad estable. El orden en que las he enumerado es también el orden en que conviene intentarlas.

✂️

1. Descomponer en primitivos

Si lo que construías era un envoltorio de dos o tres valores planos, no lo construyas: lee cada valor con su propio useSelector. Coste cero, sin comparadores ni cachés, y el contrato de render queda al mínimo.

🔍

2. Comparar superficialmente

Si el envoltorio es necesario y sus valores son estables, usa shallowEqual o useShallow. Sigue construyendo en cada despacho, pero la comparación por claves impide el render. Coste lineal en el número de claves.

🧠

3. Memoizar el cálculo

Si lo que devuelves es una derivación —filtrar, ordenar, agrupar— ni descomponer ni comparar sirven: necesitas createSelector, que evita recalcular y conserva la referencia anterior cuando las entradas no cambian.

🚚

4. Mover el trabajo

A veces la mejor respuesta es que la derivación no ocurra al leer: precalcúlala en el reducer, normaliza el estado o deja que el componente hijo lea su propia entidad en lugar de recibir una lista construida.

El criterio de elección es más nítido de lo que parece si te preguntas qué relación hay entre el resultado y el estado. Si el resultado es un reagrupamiento de valores que ya existen tal cual en el store, el problema es de envoltorio y se resuelve descomponiendo o comparando por claves. Si el resultado es un valor que no existe en el store hasta que tu selector lo calcula, el problema es de derivación y solo la memoización lo resuelve, porque ni siquiera una comparación superficial acierta cuando los elementos internos también son nuevos.

// Envoltorio: descompon.
const nombre = useSelector((s: RootState) => s.u.nombre)
const rol = useSelector((s: RootState) => s.u.rol)

// Envoltorio inevitable: compara por claves.
const datos = useSelector((s: RootState) => ({ n: s.u.nombre, r: s.u.rol }), shallowEqual)

// Derivacion: memoiza. Ninguna comparacion barata salva esto.
const selectPendientes = createSelector(
  [(s: RootState) => s.tareas.lista],
  (lista) => lista.filter((t) => !t.hecha),
)
const pendientes = useSelector(selectPendientes)

Hay una trampa clásica en el punto medio entre las dos familias que conviene señalar. Una derivación que devuelve un array de objetos existentes —filtrar una lista sin transformar sus elementos— sí puede rescatarse con una comparación superficial, porque el contenedor es nuevo pero los elementos son las mismas referencias de antes. En cambio, una derivación que además proyecta o transforma cada elemento crea objetos internos nuevos y falla incluso la comparación superficial. Distinguir filtrar de transformar decide, en ese punto, si la solución dos basta o hace falta la tres.

💡
La cuarta solución suele ser la mejor y casi nadie la considera

Las tres respuestas clásicas dan por sentado que la derivación debe ocurrir en el momento de leer. A menudo no es cierto. Si una lista filtrada se recalcula cientos de veces por sesión y su criterio cambia dos veces, guardar los identificadores filtrados en el propio estado —o normalizar y dejar que cada fila lea lo suyo— elimina el problema en lugar de administrarlo. Memoizar es la respuesta correcta cuando la derivación es genuinamente dinámica; cuando no lo es, memoizar solo pone una caché delante de un trabajo que no había que hacer ahí.

// Filtrar conserva las referencias internas: shallowEqual salva el caso.
const abiertas = useSelector(
  (s: RootState) => s.tareas.lista.filter((t) => !t.hecha),
  shallowEqual,
)

// Transformar crea objetos nuevos: shallowEqual falla en el primer elemento.
const resumen = useSelector(
  (s: RootState) => s.tareas.lista.map((t) => ({ id: t.id, txt: t.titulo })),
  shallowEqual, // inutil aqui: hace falta memoizar
)

Repara además en un coste que la segunda solución arrastra incluso cuando funciona: la comparación superficial de un array recorre todos sus elementos en cada despacho de la aplicación. Para una lista de diez elementos es irrelevante; para una de cinco mil, has cambiado un render inútil por un recorrido de cinco mil comparaciones que se ejecuta constantemente y casi siempre concluye que nada cambió. A partir de cierto tamaño, memoizar no es una alternativa más elegante sino la única que escala.

Los avisos que lo hacen visible

Lo que convierte a este bug en el más común no es su dificultad conceptual, que es nula, sino su invisibilidad: el resultado en pantalla es correcto y la degradación es gradual. Por eso la aportación más útil de las versiones recientes no fue una optimización sino un par de chequeos que se ejecutan solo en desarrollo y hablan en voz alta.

El chequeo de estabilidad ejecuta tu selector dos veces con el mismo estado en la primera llamada y compara ambos resultados. Si difieren, el selector es inestable por construcción y lo dice con nombre y apellidos. El chequeo de identidad detecta el caso contrario y también inútil: un selector cuya función de resultado devuelve su propia entrada, señal de una memoización que no memoiza nada. Ambos existen tanto en useSelector de react-redux como en los selectores de reselect, y ambos se pueden ajustar por hook o globalmente.

// Ajuste por hook: forzar el chequeo siempre, no solo en la primera llamada.
const datos = useSelector(selectAlgo, { devModeChecks: { stabilityCheck: 'always' } })

// Ajuste global al crear el store.
const store = configureStore({
  reducer,
  // los chequeos de selectores viven en el contexto de desarrollo y no
  // afectan al build de produccion, donde se eliminan por completo
})

Hay un tercer nivel de defensa que casi nadie monta y que resulta desproporcionadamente eficaz: hacer que el error no llegue a escribirse. Una regla de análisis estático que prohíba literales de objeto y llamadas a map, filter o sort dentro del argumento de useSelector convierte una clase entera de fallos de rendimiento en un error de compilación. Es la misma jugada que se hace con las dependencias de los hooks, y funciona por la misma razón: el problema es sintácticamente reconocible aunque sea semánticamente sutil.

Merece la pena entender por qué la revisión de código humana falla aquí de forma tan sistemática. El selector culpable no se ve mal: expresa con claridad lo que quiere, no tiene complejidad, no introduce estado y devuelve lo correcto. Un revisor que busque errores de lógica pasará por encima veinte veces, porque el defecto no está en lo que la función calcula sino en la identidad del valor que produce, una propiedad que el código no exhibe en su superficie. Las herramientas ganan a la atención en exactamente esta clase de errores.

Una advertencia de método para cerrar: estos avisos corren solo en desarrollo, y en desarrollo StrictMode duplica ejecuciones y el código no está optimizado. Eso significa que sirven para detectar la clase del error, no para medir su magnitud. Confirmar que un selector es inestable es trabajo de consola; decidir si esa inestabilidad justifica introducir una caché es trabajo de medición, y ese es exactamente el asunto de la última lección de este nivel.

El bug no está en el selector: está en confundir igualdad con identidad

Conviene resistir la lectura cómoda de este error, la que lo archiva como un descuido de principiante que se arregla envolviendo cosas en createSelector. Lo que este bug expone es una fractura conceptual que atraviesa todo el frontend moderno: existen dos nociones de que dos cosas sean la misma, y solo una de ellas es computable en tiempo constante. La igualdad semántica —estos dos objetos contienen lo mismo— es la que tiene el programador en la cabeza cuando escribe el selector y la que, con toda razón, le hace pensar que devolver la misma lista filtrada dos veces seguidas no debería provocar nada. La identidad referencial —estos dos objetos son el mismo objeto en memoria— es la que el sistema puede permitirse comprobar miles de veces por segundo, y es por tanto la única que puede sostener una maquinaria de reactividad que se ejecuta en cada cambio. Toda arquitectura reactiva basada en inmutabilidad se apoya en un pacto entre ambas: si prometes no mutar, entonces identidad distinta implica contenido distinto, y comparar referencias se vuelve un sustituto correcto y baratísimo de comparar contenidos. El selector que construye rompe la otra mitad del pacto, la implicación inversa, al producir identidades distintas para contenidos iguales; y en el momento en que la rompe, la comparación barata deja de ser una aproximación conservadora y pasa a ser un generador constante de falsos positivos. Por eso las tres soluciones son en el fondo la misma: restaurar la correspondencia entre lo que el programa considera igual y lo que la máquina considera idéntico, sea eliminando la construcción, sea comparando el contenido explícitamente, sea cacheando la referencia para que sobreviva a la siguiente llamada. Y por eso quien entiende esta distinción deja de necesitar reglas memorizadas sobre qué envolver: le basta preguntarse, ante cada valor que cruza una frontera reactiva, si su identidad está gobernada por los datos o por el instante en que se pidió.

⚔️ Caza y repara las referencias inestables
  1. Busca en tu base de código todos los useSelector que contengan una llave de apertura, un corchete, map, filter o sort, y haz un inventario.
  2. Clasifica cada hallazgo en envoltorio o derivación, sin arreglar nada todavía; la clasificación decide la solución.
  3. Repara los envoltorios descomponiendo en primitivos y comprueba con un contador de renders que la frecuencia baja.
  4. Repara una derivación con createSelector y verifica que la referencia devuelta se conserva entre despachos que no la afectan.
  5. Activa el chequeo de estabilidad en modo permanente durante una sesión de desarrollo y anota cuántos avisos distintos aparecen.
  6. Elige un caso donde la cuarta solución sea la buena —precalcular o normalizar— y argumenta por escrito por qué memoizar habría sido administrar el problema en vez de eliminarlo.