Inicio / Artículos

Cuando la memorización de React da resultados contrarios: por qué menos renders hicieron que la aplicación fuera más lenta.

Cuando la memorización en React tiene efectos negativos: memo, useMemo y useCallback añadieron costo y cierres obsoletos a una página grande. Mover el estado y analizar el flujo lo solucionó.

Editor: REACTAPP.TOP13 min de lectura2888 palabras

Fuentes: The React Optimization That Made Our App Slower

Un caso de arreglar lo incorrecto

Imagínese una página grande que se vuelve a renderizar cada vez que alguien escribe en un filtro. Contiene tablas, gráficos, resúmenes y varios paneles interactivos, y el React Profiler muestra una larga lista de componentes. Eso parece ser prueba de que React está realizando demasiado trabajo.

Sigue la solución habitual: los componentes se envuelven en memo, los manejadores en useCallback, los cálculos en useMemo, y los selectores reciben funciones de igualdad personalizadas. Un componente que aún se renderiza tiene más de sus propiedades estabilizadas. Un objeto cuya referencia cambia se memoriza. Una comparación que falla se reemplaza por una más profunda.

Con el tiempo, el perfilador sí muestra menos renderizaciones de componentes. Sin embargo, la aplicación no parece más rápida. Algunas interacciones se vuelven menos rápidas a la respuesta, algunas pantallas consumen más memoria y algunos componentes muestran valores obsoletos porque se omitió una dependencia para mantener estable la referencia. También se vuelve más difícil depurar, ya que el rendimiento ahora depende de cachés y reglas de comparación dispersas por todo el árbol. El equipo había ajustado un número que era fácil de ver, pero no aquello que realmente esperaban los usuarios.

Usar la memorización no fue el error, ya que React ofrece estas herramientas para abordar problemas reales. El fallo radicó en la creencia de que detener una renderización siempre es mejor que permitirla. La renderización requiere esfuerzo, al igual que decidir si se debe omitir. Cuando esa decisión implica reconstruir arrays de dependencias, comparar propiedades extensas, conservar valores antiguos y ejecutar lógica de caché personalizada, la optimización puede resultar más costosa que el trabajo que intentaba evitar.

Qué significa realmente “innecesario”

El primer error estuvo en la definición de una renderización innecesaria. Un componente apareció en el perfilador aunque su salida final en DOM no hubiera cambiado visiblemente, por lo que la renderización fue calificada como un desperdicio. Eso ignora lo económicas que pueden ser muchas componentes.

Tome un componente funcional que recibe un par de propiedades primitivas y devuelve un árbol de elementos sencillo. Se ejecuta en un instante, React compara los resultados y apenas modifica algo en el DOM, por lo que bloquear esa llamada no aporta prácticamente ningún beneficio.

La versión memorizada tiene su propio trabajo que realizar. React debe conservar las propiedades anteriores, recibir las nuevas y compararlas. Con varias propiedades, algunas de las cuales son objetos, arrays o funciones, la comparación puede fallar con frecuencia o volverse lo suficientemente costosa como para anular cualquier ventaja. El número de renderizaciones se había convertido efectivamente en una métrica de rendimiento, y un valor más bajo se consideraba mejor, incluso cuando los renderizaciones omitidos no eran la causa de la demora.

Es una trampa fácil, ya que el perfilador hace que la renderización sea visible mientras que otros costos permanecen ocultos. La comparación de propiedades, la asignación de JavaScript, la retención en caché, los selectores de almacenamiento, el diseño, el dibujo y las bibliotecas de terceros pueden dominar una interacción, mientras que el recuento de renders capta toda la atención. Vale la pena evitar una nueva renderización cuando el componente realiza un trabajo significativo y sus entradas suelen permanecer iguales. No merece la pena evitarla solo porque ha ocurrido.

Qué cuesta realmente memo

La gente suele describir memo como una protección que impide que un componente hijo se vuelva a renderizar cuando las propiedades no han cambiado. Esa descripción omite un paso: React debe primero comparar las propiedades antiguas con las nuevas antes de poder saltarse cualquier proceso.

Con propiedades primitivas, comparar es sencillo. Con contratos de componentes amplios, el costo resulta más difícil de predecir. Se pasaban objetos de fila, arrays de configuración, modelos de permisos, colecciones de callbacks y opciones anidadas. Envolver al receptor en memo no estabilizó ninguno de esos valores; simplemente añadió otra etapa en la que React verificaba si eran estables.

La mayoría de esas verificaciones fallaron, porque el padre reconstruía al menos una referencia durante el renderizado. React realizaba la comparación y, aun así, procedía con el renderizado, por lo que se añadió trabajo sin eliminar nada.

Siguieron funciones de comparación personalizadas, diseñadas para inspeccionar los valores con mayor cuidado. Un componente de grid de datos verificaba sus columnas, qué filas estaban seleccionadas, los detalles de paginación, si se estaba cargando y las propias filas. Ese comparador se convirtió en una segunda copia del modelo de dependencias del componente, y podía fallar de ambas maneras: uno exhaustivo consumía tiempo real, mientras que uno descuidado podía omitir una renderización necesaria.

Ahora existía un nuevo límite de corrección. Todo lo que influía en la renderización tenía que reflejarse en el comparador, por lo que añadir una propiedad significaba editar tanto el componente como su lógica de memorización al mismo tiempo. Los componentes que antes se renderizaban correctamente por defecto solo permanecían así mientras una función de igualdad mantenida a mano los describiera sin defectos.

Comparación profunda

Cuando la comparación superficial falla con demasiada frecuencia, la comparación profunda parece ser el siguiente paso lógico. Si dos objetos de configuración contienen los mismos valores, ¿por qué deberían referencias diferentes forzar una renderización? Compare el contenido y omita la acción cuando sean estructuralmente iguales.

Eso funciona en demostraciones con objetos pequeños. En una aplicación real, algunas propiedades albergan cientos o miles de valores, y una comparación profunda recorre arrays, inspecciona objetos anidados y repite todo esto en cada actualización del padre. El componente protegido podría renderizar solo unas pocas filas simples, o devolver un componente de terceros con su propia memorización para subárboles estables. La comprobación de igualdad puede costar más que simplemente llamar al componente.

La comparación profunda también escala con el tamaño de la entrada y no con el tamaño del cambio. Una sola actualización pequeña del estado puede hacer que el comparador revise toda una colección para demostrar que la mayor parte de ella no ha cambiado.

La estructura tampoco siempre constituye una base segura para la comparación. Las funciones, fechas, mapas, instancias de clases y objetos de bibliotecas pueden tener un comportamiento o identidad que una función genérica de igualdad profunda no puede interpretar, por lo que incluso una comparación que parezca exhaustiva puede mantener un comportamiento obsoleto. El equipo abandonó el sencillo modelo de referencia de React en favor de un sistema personalizado de igualdad semántica sin definir nunca qué significaba la igualdad para cada valor del dominio.

La solución más efectiva rara vez consistía en comparar objetos inestables de manera más exhaustiva. Se trataba de mantener su identidad de forma natural cuando los datos subyacentes no habían cambiado, de restringir el contrato de propiedades o de acercar la actualización al componente que los gestionaba.

Memorización de valores

Luego vinieron los valores. Los arrays filtrados, los objetos de configuración, las etiquetas formateadas, los mapas de búsqueda y los cálculos sencillos recibían useMemo una vez que los componentes que los rodeaban ya estaban memorizados.

Algunos realmente lo merecían. Ordenar una gran colección o armar un modelo complejo puede justificar el uso del caché si los datos de entrada cambian con menos frecuencia que la actualización del componente padre. Pero muchos no lo necesitaban. Concatenar dos cadenas de texto o crear un objeto pequeño es menos costoso que todo el entorno que lo rodea: el componente sigue generando un array de dependencias, React sigue comparando cada elemento con el anterior, el resultado anterior sigue ocupando memoria y los desarrolladores siguen teniendo que determinar si la lista está completa.

El código también se volvió visualmente más complejo. Un valor que antes se expresaba en una sola fórmula terminaba envuelto en un Hook y en un array de dependencias.

const label = useMemo(
  () => `${firstName} ${lastName}`,
  [firstName, lastName]
);

En tiempo de ejecución, la caché añadía prácticamente nada, lo que indicaba que el cálculo no era lo suficientemente importante como para optimizarlo. Peor aún, distribuir useMemo por todas partes oscurecía el flujo de datos. Los desarrolladores mantenían estables las referencias a objetos no porque su creación fuera costosa, sino porque algún otro componente memorizado dependía de su identidad. Cada optimización exigía otra, lo que generaba un árbol de cachés que solo funcionaba mientras todos los enlaces se mantuvieran válidos.

La memorización rinde más beneficios cuando permite conservar trabajos costosos. En este caso, se trataba de preservar las referencias que requerían otras técnicas de memorización, lo cual es un objetivo muy diferente.

Callbacks y cierres obsoletos

Los callbacks creados durante el renderizado obtienen una nueva identidad cada vez. Al pasárselos a hijos memorizados, esas nuevas referencias hacen que la comparación de esos hijos fallé. La solución fue envolver casi todos los manejadores en useCallback.

Eso redujo en parte la rotación de referencias, pero generó listas de dependencias más largas. Un manejador de guardado dependía del borrador, del registro seleccionado, del usuario actual, de los permisos, de un servicio de notificaciones y de la función de mutación; cualquier cambio en alguno de ellos producía de todos modos un nuevo callback. El callback rara vez permanecía lo suficientemente estable como para ser reutilizado. El hijo se renderizaba de todos modos, y el padre acumulaba un Hook adicional y un contrato de dependencia más.

A veces, los desarrolladores eliminan dependencias para mantener estables las funciones de callback, lo que genera cierres obsoletos. Una función de callback sigue leyendo un borrador antiguo, un valor de permiso desactualizado o un registro seleccionado anteriormente, ya que preservar la identidad de la función se ha vuelto más importante que mantener su comportamiento actual. Este tipo de código puede superar las pruebas en el camino de interacción inicial y fallar solo después de que cambie el estado sin que se vuelva a crear la función de callback. Estos errores son difíciles de detectar porque la función existe y se ejecuta sin errores; simplemente actúa sobre datos antiguos. La identidad de las funciones de callback se ha optimizado a expensas de la actualidad de los datos.

Una pregunta más adecuada es si el componente hijo realmente necesita una función de callback estable desde un principio. En el caso de un componente hijo económico que no está memorizado, useCallback apenas sirve de algo. Si la función de callback se utiliza para alimentar una suscripción o un mecanismo de memorización costoso, la estabilidad es importante, y sus dependencias a menudo pueden reducirse al cambiar quién es el propietario, pasar una operación más limitada o colocar el comportamiento más cerca del estado que lee. Una función con identidad fija no es necesariamente más rápida por naturaleza; solo ayuda cuando algo significativo la reutiliza.

Los cachés utilizan memoria

El cacheo evita cálculos repetidos al guardar los resultados anteriores, y esa conservación tiene un costo en términos de memoria. Un valor pequeño memorizado rara vez representa un problema de memoria por sí solo. Sin embargo, en una aplicación grande, muchos componentes pueden contener arrays, mapas, registros transformados, modelos formateados y cierres que capturan más datos.

Esto explica en parte por qué una sola vista del perfilador no puede describir el rendimiento. Un cálculo en caché puede desaparecer de la línea de tiempo de renderizado, aunque posteriormente contribuya al aumento de la memoria y a pausas más largas. Que esto sea beneficioso depende del tamaño del resultado, de los costos de reconstrucción, de la frecuencia con la que se reutiliza y del tiempo que permanece montado el componente. Un modelo derivado grande almacenado en caché para un panel que rara vez se abre puede costar más que reconstruirlo cuando sea necesario. Los cachés no son almacenamiento gratuito para trabajos antiguos; intercambian cálculos por estado conservado y más lógica de invalidación.

El verdadero problema era dónde residía el estado

El hallazgo clave fue que los nuevos renders reflejaban dónde se ubicaba el estado. Un filtro que cambiaba constantemente se encontraba en el componente de la página, el cual también renderizaba los gráficos, los resúmenes, una tabla grande y varios paneles. Cada pulsación de tecla volvía a renderizar la página y reconstruía las propiedades de cada hijo.

Proteger a los niños mediante la memorización significaba estabilizar sus arrays, objetos y funciones de callback, además de los selectores y comparaciones personalizados, mientras la arquitectura seguía girando en torno a un único límite de actualización. Al mover el estado del filtro junto a la tabla, se resolvió la mayor parte del problema, y el resto de la página ya no necesitaba protegerse.

En otros lugares ocurrió lo mismo: el estado de los formularios de diálogo se encontraba en el componente de pantalla, el estado de hover existía sobre toda una colección de tarjetas, un contexto contenía valores con tasas de actualización muy diferentes, y un selector de almacén global devolvía más datos de los que necesitaba su consumidor. La memorización servía solo para ocultar las decisiones de gestión de propiedades que permitían que pequeños cambios afectaran áreas demasiado amplias.

Después de que se reforzaron esos límites, muchos niveles de memorización dejaron de ser útiles. Los componentes que no estaban en el camino de actualización dejaron de renderizarse, ya que el estado ya no se encontraba por encima de ellos, y los hijos tuvieron contratos más simples porque el padre ya no los gestionaba minuciosamente.

Menos renders, misma carga del navegador

Incluso en los casos en que la memorización evitaba los renders de React, las interacciones a veces seguían siendo lentas porque React no era el principal factor de costo.

Una biblioteca de gráficos seguía realizando actualizaciones imperativas costosas. Un gran árbol DOM aún necesitaba un reajuste de layout cuando cambiaban sus dimensiones. Un efecto CSS forzaba repintes extensos, una escritura síncrona en el almacenamiento bloqueaba el hilo principal, y un manejador de entrada analizaba un documento grande antes de que pudiera aparecer el siguiente carácter. Los componentes habían llamado la atención únicamente porque eran lo que medía el perfilador, y a medida que el rendering se volvía más económico, el trabajo del navegador detrás de él se volvía visible.

El rendimiento de React es solo una parte del rendimiento del frontend, no un sustituto de él. Incluso con la memorización activada, un cambio en la dependencia de un Effect puede hacer que un widget de terceros se reconstruya. Omitir la renderización de un padre no reduce el tiempo de decodificación de una imagen de gran tamaño. Una función de callback estable no soluciona los problemas de layout causados por leer y escribir las medidas de los elementos en el orden incorrecto. Es necesario rastrear todo el proceso a través de JavaScript, la renderización de React, las mutaciones en el DOM, el cálculo de estilos, el layout, el dibujo y la red, ya que acelerar una sola etapa podría dejar intacto todo el proceso. React permanecía en silencio mientras el navegador seguía ocupado.

Código más difícil de modificar

El costo en rendimiento era solo la mitad del problema. La aplicación también se volvió más difícil de mantener.

Cada límite entre componentes planteaba nuevas preguntas. ¿Está el componente memorizado? ¿Qué propiedades necesitan referencias estables? ¿Un comparador personalizado cubre la nueva propiedad? ¿Se dejó intencionalmente algún valor fuera de la lista de dependencias? ¿Cambiar la estructura de un objeto invalidará varios cachés? El trabajo rutinario en las funcionalidades requería comprender contratos de optimización que no tenían nada que ver con el comportamiento del producto.

Imagínese a un desarrollador que agrega un campo a un objeto de configuración y, accidentalmente, provoca actualizaciones masivas. Un colega “arregla” eso con una comparación más detallada. Más tarde, alguien agrega una función al objeto y descubre que el comparador trata como iguales dos configuraciones claramente diferentes. El código estaba ajustado a su forma actual, y cualquier modificación perturbaba una red delicadamente equilibrada de referencias y arrays de dependencias.

Una arquitectura de buen rendimiento hace que los cambios comunes sean más económicos y no obliga a que cada cambio respete suposiciones ocultas sobre la caché. Eso no significa que el código optimizado deba seguir siendo simple. Algunas interacciones de alto costo sí justifican el uso de mecanismos especializados de memorización y un manejo cuidadoso de las referencias. La complejidad debería ser proporcional al beneficio obtenido y concentrarse en los puntos críticos conocidos, mientras que aquí se había distribuido por todas partes bajo la premisa de que cualquier renderizado podría convertirse en un punto crítico algún día.

La medición de las interacciones cambió la decisión

El punto de inflexión fue realizar el perfilado de las interacciones completas de los usuarios en lugar de contar los renderizados.

Se registraron las acciones que los usuarios consideraron lentas y se analizó dónde se gastaba el tiempo. Varios componentes se renderizaban con frecuencia pero casi no se utilizaban. Algunos cálculos y widgets de terceros eran los que dominaban, mientras que las comparaciones personalizadas se registraban como trabajo real de JavaScript. En algunos casos, eliminar la memorización causó un número ligeramente mayor de renderizaciones, pero menos tiempo total.

Al principio pareció incorrecto, ya que el modelo mental utilizado consideraba que menos renderizaciones repetidas significaban progreso. Al navegador no le importa cuántas veces se llaman las funciones de los componentes; le importa cuánto trabajo se realiza antes del siguiente frame de respuesta.

A partir de entonces, los cambios se evaluaron en función del costo total de interacción. ¿La entrada respondía más rápido? ¿Quedaba libre el hilo principal? ¿La memoria se mantenía estable durante sesiones prolongadas? ¿Mejoraban las versiones de producción, y no solo las de desarrollo? Las correcciones también se volvieron locales: una transformación costosa, un subárbol grande o una configuración inestable de biblioteca podían manejarse directamente, mientras el resto de la aplicación dejaba de utilizar su mecanismo de memorización defensiva. El perfilamiento no permitió un rediseño; simplemente evitó que el equipo se centrara en un número visible que no reflejaba la experiencia del usuario.

Lo que se mantuvo

No se eliminaron todos los memo, useMemo y useCallback, ya que eso repetiría el error, pero en sentido inverso.

La memorización se mantuvo en componentes costosos cuyos propios permanecían prácticamente inmutables. Las transformaciones que resultaban costosas y se reutilizaban en renders frecuentes de los componentes padre se almacenaban en caché, y las funciones de callback se mantuvieron estables cuando su identidad era importante para las suscripciones, las integraciones de terceros o los límites de memorización significativos.

Lo que desapareció: cachés para cálculos triviales, funciones de comparación personalizadas que realizaban verificaciones estructurales amplias, y la reticencia a permitir que los componentes económicos se renderizaran. El estado que cambiaba con frecuencia se acercó más a las interfaces que lo utilizaban, y los valores de contexto con pocos puntos en común se separaron. El resultado no fue el menor número posible de renders. Fue un modelo de actualización más fácil de entender, con optimizaciones aplicadas donde la evidencia lo justificaba. Eso hizo que la aplicación fuera más rápida y más fácil de modificar.

El rendering no es el enemigo

Esta historia comenzó con un instinto razonable: ver que algo se está ejecutando e intentar detenerlo. El error fue tratar cada renderizado como un desperdicio y cada renderizado omitido como una victoria.

La memorización compara entradas, almacena valores, sigue las dependencias y debe mantenerse correcta a medida que cambia el código. Una lógica de igualdad personalizada puede costar más que un renderizado. Las funciones de callback estables pueden congelar un estado obsoleto. Los resultados en caché ocupan memoria. Y un enredo de optimizaciones puede hacer que sea difícil modificar un árbol de componentes sin que los usuarios se den cuenta de ningún beneficio.

El renderizado en React suele ser económico. La débil gestión del estado, los cálculos repetitivos y costosos, las suscripciones amplias, las actualizaciones de terceros y el diseño del navegador suelen ser factores mucho más importantes. La medida más efectiva fue permitir que las tareas económicas siguieran siéndolo: dejar de envolver componentes comunes en cachés y concentrarse en las pocas operaciones realmente costosas. La optimización debe reducir el costo total de una interacción, no solo el número de veces que React llama a una función. La velocidad apareció cuando la pregunta pasó de cómo evitar que React renderice a por qué cada actualización se había vuelto tan costosa.

Leer más

  • Por qué el frontend es difícil después de React: estado, efectos, límites y herramientas de IA — React hizo que las interfaces de usuario fueran más fáciles de construir, no de diseñar. Donde realmente reside el trabajo en el frontend son el estado, los efectos complicados, los límites del sistema y el código generado por IA.
  • Agregar una pantalla de bienvenida a una app React Native en iOS y Android — Guía paso a paso para configurar una pantalla de bienvenida en React Native para iOS y Android: pantalla de inicio de Xcode, temas y diseños para Android, y react-native-splash-screen para evitar el destello blanco.
  • 30 hábitos de React y Redux para interfaces frontales escalables y mantenibles — Treinta consejos breves sobre React y Redux relacionados con el estado, los datos del servidor, los hooks, los selectores, la estructura de carpetas, las pruebas y el refactoring, dirigidos a aplicaciones que superan las mil componentes.