Se actualiza de más: la dependencia sobrante
El fallo simétrico al anterior y bastante más caro de encontrar, porque no rompe nada: la aplicación funciona, solo trabaja diez veces más de lo necesario. Esta lección invierte el árbol de diagnóstico para buscar aristas de sobra en el grafo de dependencias en lugar de aristas ausentes, y diseca las tres fuentes que producen casi todas: el objeto o la función creados de nuevo en cada render, que fabrican una identidad distinta a partir de un contenido idéntico; el selector que suscribe a más superficie de la que su componente consume, incluida la variante que devuelve un objeto nuevo y anula la comparación superficial; y la cascada de efectos, en la que un efecto escribe el estado del que otro depende y el sistema converge tras varias vueltas. Cierra con el criterio para decidir cuándo una actualización de más merece corregirse y cuándo la corrección cuesta más que el problema.
El fallo contrario tiene mala fama de ser menos grave y en realidad es más caro de encontrar, porque no rompe nada. La aplicación funciona: los datos son correctos, la pantalla muestra lo que debe, nadie abre un informe de error. Simplemente, cada pulsación repinta media interfaz, cada tecleo dispara tres peticiones y el ventilador del portátil se enciende cuando el usuario escribe su nombre. Nadie llama a esto un bug hasta que la lista tiene mil filas o el dispositivo tiene cuatro años, y entonces ya lleva meses instalado y ha echado raíces en veinte componentes. Diagnosticarlo exige invertir el árbol de la lección anterior: allí faltaba una arista en el grafo de dependencias, aquí sobra. Y sobra casi siempre por la misma razón, una razón tan fundamental que conviene enunciarla antes de ver un solo ejemplo: en un sistema que decide por identidad, dos valores idénticos en contenido pero distintos en referencia son, a todos los efectos, un cambio. Las tres fuentes que estudia esta lección son tres maneras distintas de fabricar exactamente esa mentira.
- Invertir el árbol de diagnóstico para buscar aristas sobrantes en lugar de aristas ausentes.
- Identificar el objeto o la función recreados en cada render como falsificadores de identidad.
- Corregir el selector que suscribe a más superficie de estado de la que su componente consume.
- Detectar las cascadas de efectos y distinguirlas de una simple actualización redundante.
La identidad que miente
Contenido igual, referencia nueva. Un literal creado durante el render engaña a cualquier comparador basado en Object.is.
El selector demasiado ancho
Suscribe al componente a una rama entera del store cuando solo consume un campo. Todo el ruido de esa rama le llega.
La cascada de efectos
Un efecto escribe lo que otro observa. El sistema converge tras varias vueltas y cada vuelta es un render completo.
El diagnóstico inverso: no busques el cambio, busca al que lo provocó
La pregunta que abre este árbol no es qué cambió, sino quién lo declaró como dependencia. Es un giro incómodo porque el ruido visible —el componente que se repinta— es de nuevo el último eslabón, y de nuevo la información útil está en el otro extremo: en la lista de dependencias que ese componente o ese efecto anunciaron al sistema. Un repintado excesivo siempre significa lo mismo, sin excepción: alguien declaró depender de algo que no le importa, o algo que sí le importa cambió de identidad sin cambiar de valor.
La instrumentación mínima consiste en comparar las dependencias entre dos ejecuciones consecutivas y reportar cuáles difieren en referencia. Es tosco y es infalible, y responde en una pasada lo que el razonamiento tarda una tarde en conjeturar.
// Comparar dependencias entre ejecuciones: la version cruda del why did you render.
function useQueCambio(nombre: string, deps: Record<string, unknown>) {
const previo = useRef(deps)
useEffect(() => {
const distintas = Object.keys(deps).filter((k) => !Object.is(previo.current[k], deps[k]))
if (distintas.length) console.log(nombre, 'cambiaron por identidad:', distintas)
previo.current = deps
})
}
Cuando esa traza señala una clave cuyo contenido es visiblemente el mismo de antes, ya tienes el diagnóstico: no cambió el valor, cambió la referencia. Los tres apartados siguientes son los tres lugares donde eso ocurre.
Antes de recorrerlos conviene fijar el criterio que evita la trampa opuesta, que consiste en perseguir cada render sobrante como si fuera un defecto moral. Un render de más no es un bug por sí mismo: es un coste. Se convierte en bug cuando ese coste es perceptible —una lista larga que se recorre entera, un cálculo caro que se repite, una petición que se dispara— o cuando el trabajo se multiplica en cascada. Un componente trivial que se repinta el doble de lo necesario no justifica ni una línea de memoización, y añadírsela empeora el código a cambio de nada medible. Mide primero, corrige después, y corrige solo donde la medición dolió.
Fuente uno: el objeto nuevo en cada render
Es la más común con enorme diferencia y la más invisible, porque el código que la produce es idiomático y bonito. Cualquier literal de objeto, array, función anónima o expresión regular escrito en el cuerpo de un componente se construye de cero en cada render. Su contenido no cambia jamás, pero su identidad cambia siempre, y cualquier consumidor que lo reciba como dependencia o como prop memoizada lo interpretará como novedad legítima en cada vuelta.
// Cada render fabrica un objeto distinto con el mismo contenido.
const opciones = { orden: 'asc', pagina: 1 }
useEffect(() => { cargar(opciones) }, [opciones]) // se ejecuta siempre
// Correcciones, de la mas barata a la mas estructural.
const OPCIONES = { orden: 'asc', pagina: 1 } // fuera del componente: una sola vez
useEffect(() => { cargar({ orden, pagina }) }, [orden, pagina]) // depende de primitivos
const opts = useMemo(() => ({ orden, pagina }), [orden, pagina]) // identidad estable
La misma trampa afecta a las funciones, y ahí resulta todavía más invisible porque una función anónima pasada como prop parece obviamente inocua. No lo es: cada render fabrica un cierre nuevo, y ese cierre nuevo invalida cualquier hijo memoizado que la reciba, destruyendo justamente la optimización que alguien puso ahí con esfuerzo. El resultado es lo peor de ambos mundos, el coste de la comparación sin el beneficio del ahorro. Lo mismo ocurre con los valores de contexto: un proveedor que construye su valor como literal en el cuerpo del render despierta a todos sus consumidores en cada vuelta, por lejos que estén y por poco que consuman.
La jerarquía de correcciones importa tanto como la corrección misma. Sacar el valor fuera del componente es la mejor cuando el valor es constante, porque elimina el problema en vez de gestionarlo. Depender de primitivos en lugar del objeto que los contiene es la mejor cuando el valor sí varía, porque los primitivos se comparan por valor y la identidad deja de ser un tema. Memoizar es la última opción, no la primera: convierte un problema de diseño en un problema de mantenimiento, ya que a partir de ese momento alguien debe mantener correcta la lista de dependencias del useMemo para siempre.
El React Compiler memoiza automáticamente esos literales y elimina de un plumazo buena parte de esta categoría, y algo equivalente hacen los frameworks de grano fino que nunca reejecutan el cuerpo del componente. Es una mejora real y conviene adoptarla. Pero no sustituye al diagnóstico por dos motivos concretos. El primero es que el compilador solo puede estabilizar lo que ve: un objeto que llega desde una librería externa, desde un contexto no compilado o desde una función que el analizador no puede probar como pura sigue cambiando de identidad. El segundo es que memoizar de forma automática no arregla un selector demasiado ancho ni una cascada de efectos, que son las otras dos fuentes de esta lección y las que sobreviven a cualquier compilador. Entiéndelo como lo que es: la automatización del remedio más mecánico, no la desaparición del problema.
Fuente dos: el selector que suscribe a más de lo que consume
La segunda fuente vive en la frontera entre el store y el componente, y tiene dos variantes que conviene no confundir. La primera es el selector demasiado ancho: el componente pide una rama entera del estado y usa un campo, de modo que queda suscrito a todo el ruido de esa rama. La segunda es más traicionera: el selector devuelve un objeto construido al vuelo para agrupar varios campos, y ese objeto tiene identidad nueva en cada evaluación, así que el comparador por referencia del store concluye que el resultado cambió aunque los campos sean idénticos.
// Demasiado ancho: se suscribe a todo el usuario para pintar un nombre.
const usuario = useStore((s) => s.usuario)
return <span>{usuario.nombre}</span>
// Estrecho: solo el campo consumido, comparado por valor.
const nombre = useStore((s) => s.usuario.nombre)
// La trampa: objeto nuevo en cada evaluacion, la comparacion por referencia siempre falla.
const { nombre, rol } = useStore((s) => ({ nombre: s.usuario.nombre, rol: s.usuario.rol }))
const { nombre, rol } = useStore(selector, shallow) // con comparacion superficial si es necesario
Hay una tercera variante que combina lo peor de las dos y aparece cuando el selector hace trabajo además de seleccionar. Un selector que ordena, filtra o mapea devuelve un array nuevo en cada evaluación aunque su entrada sea idéntica, con lo que falla la comparación por referencia exactamente igual que el objeto agrupador, y encima paga el coste del cálculo en cada intento. La corrección aquí no es comparar superficialmente —un array de mil elementos comparado elemento a elemento en cada notificación es un remedio peor que la enfermedad— sino memoizar la derivación fuera del selector, de modo que el selector devuelva una referencia estable y el cálculo ocurra solo cuando su fuente cambie de verdad.
La regla que gobierna las dos variantes es una sola: el selector debe devolver el valor más estrecho y más primitivo que el componente realmente consume. Dos selectores estrechos casi siempre baten a uno ancho, porque cada uno se compara por valor y ninguno arrastra al otro. Y si de verdad necesitas varios campos en una sola lectura, la comparación superficial es obligatoria, no opcional, porque sin ella el selector agrupador es estrictamente peor que no tener selector.
flowchart TD R[render de mas] --> C[que dependencia cambio de identidad] C -->|literal creado en el render| A[sacar fuera memoizar o depender de primitivos] C -->|resultado del selector| B[estrechar el selector o comparar superficialmente] C -->|estado escrito por otro efecto| D[cascada: derivar en vez de sincronizar] A --> OK[grafo minimo] B --> OK D --> OK style R fill:#f38ba8,color:#11111b style OK fill:#a6e3a1,color:#11111b style A fill:#89b4fa,color:#11111b style B fill:#89b4fa,color:#11111b style D fill:#89b4fa,color:#11111b
Fuente tres: la cascada de efectos
La tercera fuente es la más grave de las tres porque no multiplica el trabajo por dos: lo multiplica por el número de vueltas que el sistema tarda en converger. Ocurre cuando un efecto escribe una porción de estado de la que depende otro efecto, que a su vez escribe una tercera. Cada escritura provoca una ronda completa de renders y notificaciones, y el conjunto acaba estabilizándose después de tres o cuatro pasadas que el usuario percibe como un parpadeo, una animación entrecortada o un contador que salta antes de asentarse.
El diagnóstico es directo: cuenta las rondas. Si una sola interacción produce varios ciclos de render en el profiler, no tienes un render de más, tienes una cascada. Y la corrección casi nunca consiste en afinar dependencias, porque la cascada es un síntoma de diseño: significa que estás sincronizando a mano dos piezas de estado en lugar de derivar una de la otra.
// Cascada: dos estados sincronizados a mano, tres rondas por interaccion.
const [lineas, setLineas] = useState<Linea[]>([])
const [total, setTotal] = useState(0)
const [envioGratis, setEnvioGratis] = useState(false)
useEffect(() => { setTotal(lineas.reduce((s, l) => s + l.precio, 0)) }, [lineas])
useEffect(() => { setEnvioGratis(total > 50) }, [total])
// Sin cascada: una sola fuente y dos consecuencias derivadas en la lectura.
const [lineas, setLineas] = useState<Linea[]>([])
const total = lineas.reduce((s, l) => s + l.precio, 0)
const envioGratis = total > 50
Un valor que se calcula a partir de otro no debe guardarse ni sincronizarse mediante un efecto; debe derivarse en el punto de lectura, y entonces la cascada desaparece porque no había dos estados, había uno y dos consecuencias. Fíjate en que la versión corregida no solo es más rápida: es incapaz de quedar desincronizada, porque las dos consecuencias no tienen existencia propia que mantener al día. La regla operativa que resume el apartado es tajante y merece la pena grabarla: un efecto cuyo único trabajo es escribir estado derivado de otro estado es casi siempre un error de diseño, no una optimización pendiente.
Merece la pena detenerse en la simetría, porque revela que estas dos lecciones no son dos temas sino uno. En la lección anterior faltaba una arista en el grafo de dependencias; en esta sobra. En aquella el sistema no fue informado de un cambio real; en esta fue informado de un cambio que no existía. Y en ambos casos la causa profunda es idéntica: una discrepancia entre el grafo que tú creíste declarar y el grafo que el sistema realmente construyó. Depurar reactividad, en las dos direcciones, no es más que reducir esa discrepancia hasta cero. Esto tiene una consecuencia que reordena la manera de pensar el problema. Si el grafo real es lo que importa, entonces la solución de fondo no está en el remedio local —memoizar aquí, estrechar el selector allá— sino en escribir código cuyo grafo declarado y grafo efectivo coincidan por construcción. Depender de primitivos en vez de objetos hace que la identidad deje de poder mentir. Derivar en vez de sincronizar hace que no exista una segunda arista que mantener al día. Leer estrecho y tarde hace que la suscripción abarque exactamente lo consumido, ni un campo más ni uno menos. Fíjate en que las tres son las mismas recomendaciones que curaban el fallo opuesto, y no por casualidad: un grafo mínimo y honesto no puede tener aristas de sobra ni aristas de menos, porque solo tiene las que son. Por eso el experto no memoiza defensivamente ni suscribe por si acaso; construye el grafo con la forma correcta y luego apenas necesita depurarlo. La memoización es lo que haces cuando ya es tarde para diseñar.
- Instala la utilidad de comparación de dependencias en los tres componentes que más se repintan de tu aplicación y registra qué clave falla la comparación por identidad.
- Localiza todos los literales de objeto, array y función escritos en el cuerpo de un componente y clasifícalos en constantes, derivables de primitivos y genuinamente memoizables.
- Convierte al menos uno de ellos en dependencia de primitivos y comprueba que el render de más desaparece sin añadir un solo
useMemo. - Audita tus selectores: mide cuántos campos suscribe cada uno frente a cuántos consume su componente, y estrecha los que tengan peor proporción.
- Encuentra un selector que devuelva un objeto construido al vuelo y comprueba, antes de arreglarlo, que se dispara en cada cambio del store aunque sus campos no varíen.
- Busca una cascada contando rondas de render tras una única interacción, y elimínala derivando el valor en lugar de sincronizarlo con un efecto.