Nueve patrones comunes que provocan re-renderizaciones innecesarias de React
Explica nueve patrones cotidianos de estado y efectos en React que amplían silenciosamente el alcance de las actualizaciones, y cómo reestructurar los componentes para mantener las actualizaciones localizadas.
Fuentes: 9 React Habits That Quietly Cause Unnecessary Re-Renders
React tiende a ejecutarse rápidamente por sí solo. Los problemas surgen cuando una pequeña actualización obliga a una parte mucho mayor del árbol de componentes a reevaluarse de lo que realmente requiere el cambio.
Durante mucho tiempo, pareció razonable considerar que la re-renderización excesiva era algo que React mismo debía solucionar. Cuando un componente se actualizaba más de lo necesario, la reacción habitual era envolverlo en memo, o recurrir a useMemo y useCallback. Si la interfaz seguía siendo lenta, se aplicaba el mismo tratamiento a los componentes siguientes, con la esperanza de que React finalmente se estabilizara.
Ese tipo de parches a veces hacía que el gráfico del perfilador se viera mejor, pero rara vez lograba hacer más clara la estructura de los componentes subyacentes. State seguía situado en un nivel más alto del árbol de lo necesario, los valores de contexto seguían agrupados juntos, los datos derivados seguían duplicándose, las referencias seguían siendo inestables, y Effects seguía iniciando actualizaciones adicionales en cuanto se ejecutaban. La memorización se utilizaba, de hecho, para proteger la interfaz de usuario de un flujo de datos más amplio del necesario.
Una vuelta a renderizar por sí sola no es un error. React está diseñado para llamar nuevamente a las funciones de tus componentes cada vez que sus entradas puedan haber cambiado, y muchos componentes son lo suficientemente simples como para que bloquear cada renderización inofensiva añada más complejidad de la que ahorra.
La verdadera pregunta no es si un componente se volvió a renderizar, sino por qué se activó esa renderización y qué operaciones tuvieron lugar una vez que React la invocó.
La mayoría de las actualizaciones innecesarias se deben a hábitos que parecen inofensivos mientras un componente es pequeño. Su costo solo se hace evidente más tarde, una vez que el componente crece, sus hijos se vuelven más pesados o un valor que cambia rápidamente comienza a invalidar una gran parte de la página.
1. Mantener el estado que cambia con frecuencia en una posición demasiado alta en el árbol
Elevar el estado a un ancestro compartido es uno de los primeros patrones que aprenden los desarrolladores de React. Cuando dos componentes dependen del mismo valor, asignarle la responsabilidad a su padre común más cercano los mantiene sincronizados y evita fuentes de verdad duplicadas.
Esto se convierte en un problema cuando cada pequeño fragmento de estado generado por interacciones locales es elevado al componente a nivel de página, simplemente porque ese componente tiene acceso a todo.
Un cuadro de búsqueda mantiene su valor de texto en el panel de control. La marca de estado abierto/cerrado de un menú desplegable se encuentra junto a los datos de la tabla. El estado de suspensión sobre un elemento se registra mediante la cuadrícula de tarjetas. Los campos del formulario interno de un modal son gestionados por la pantalla que lo activa. Cada tecla pulsada o interacción menor obliga ahora a ese propietario de nivel superior a volver a renderizar.
Dado que el componente de página devuelve sus hijos actualizados cada vez, actualizar ese texto de búsqueda puede hacer que los gráficos, tablas, elementos de navegación y diálogos se ejecuten nuevamente, aunque ninguno de sus datos reales haya cambiado.
Tampoco se trata de ocultar cada elemento del estado lo más profundo posible. El estado debe encontrarse en el límite más pequeño que aún necesite coordinar los componentes relevantes. Un filtro del cual dependan la URL, la tabla y un botón de exportación probablemente sí debería ubicarse por encima de esas áreas. No suele ser necesario que una sola herramienta de ayuda esté allí si está actualmente abierta.
Esta distinción es especialmente importante con valores que se actualizan rápidamente. La escritura, el arrastre, el seguimiento del cursor, el progreso de las animaciones y los campos de formulario en tiempo real pueden generar muchas actualizaciones por segundo. Si se coloca uno de estos elementos por encima de un subárbol costoso, una interacción puramente local se convierte en una carga para toda la página.
Acercar el estado a los componentes que realmente lo utilizan tiende a solucionar los problemas de rendimiento de manera más directa que envolver toda la página en mecanismos de memorización, ya que React no cuenta con un padre al cual volver a invalidar una vez que ese padre deja de poseer el estado en cuestión.
2. Uso de un único contexto para valores no relacionados
El contexto existe para resolver un problema específico: permitir que los componentes descendientes lean valores compartidos sin obligar a cada componente intermedio a transmitir propiedades que nunca utiliza.
Los problemas surgen cuando un único proveedor se convierte en el contenedor general para todo lo que se considera “global”.
Es común encontrar un contexto en toda la aplicación que contiene información como el usuario actual, el tema, la cantidad de notificaciones sin leer, la visibilidad de la barra lateral, la configuración regional, las banderas de funcionalidad, los permisos y un par de funciones de acción. Parece ordenado: un proveedor y un Hook personalizado para consumirlo.
Pero desde el punto de vista de las actualizaciones, estos valores no comparten ninguna responsabilidad común. Las cantidades de notificaciones pueden actualizarse varias veces por minuto; el tema puede permanecer sin cambios durante meses. La visibilidad de la barra lateral es un estado local de la interfaz, mientras que el usuario autenticado refleja la sesión en sí. Agruparlos significa que cada consumidor se suscribe efectivamente a cambios de los que tal vez no le importen en absoluto.
A menudo, el proveedor también reconstruye su valor de contexto en cada renderizado:
function AppProvider({ children }) {
const [theme, setTheme] = useState("light");
const [notifications, setNotifications] = useState([]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const value = {
theme,
setTheme,
notifications,
setNotifications,
sidebarOpen,
setSidebarOpen,
};
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
Cada vez que el proveedor vuelve a renderizar, value se convierte en un objeto completamente nuevo. Los consumidores lo perciben como un cambio por referencia, incluso si el campo específico que leen en realidad no ha cambiado.
Memorizar el valor pasado al proveedor puede evitar las actualizaciones repetidas causadas por cambios en un padre no relacionado, pero no permite dividir el valor del contexto en partes independientes. Si las notificaciones cambian, todo el objeto combinado sigue considerándose modificado.
Un enfoque mejor es dividir el contexto según las líneas de responsabilidad y la frecuencia de actualización, en lugar de priorizar la comodidad de tener un único archivo del proveedor. La configuración que rara vez cambia puede residir en un contexto, mientras que los datos en tiempo real que evolucionan rápidamente pueden tener el suyo propio. El estado de diseño puramente local puede no necesitar pasar por el contexto a nivel de toda la aplicación.
El contexto no es solo un mecanismo de transporte para valores, sino también uno para distribuir actualizaciones. La forma en que se estructura el valor del proveedor determina hasta qué punto puede extenderse cada cambio.
3. Reconstrucción de objetos, arrays y funciones cuando la identidad realmente importa
Crear un valor nuevo en cada renderizado es un comportamiento habitual de JavaScript. Un objeto inline, un array filtrado o una nueva función flecha suelen tener un costo tan bajo que intentar evitarlo no te aportará nada.
Las cosas se complican cuando alguna lógica posterior depende de la identidad de ese valor y no solo de su contenido.
Un hijo memorizado verifica sus props por referencia. Un Effect comprueba su lista de dependencias por referencia. Un componente de tabla podría reconstruir sus estructuras internas cada vez que cambie la referencia del array de columnas. Un proveedor de contexto informa a los suscriptores sobre una actualización siempre que el valor que almacena apunte a una nueva referencia.
Dos objetos pueden contener datos idénticos y, aun así, seguir siendo dos objetos distintos en la memoria. Si un hijo recibe un objeto de configuraciones completamente nuevo en cada renderizado del padre, no tiene forma de saber que en realidad nada ha cambiado en esas configuraciones.
Una reacción común es envolver cada objeto en useMemo y cada función en useCallback por defecto. Eso simplemente sustituye un hábito reflejo por otro. La memorización no es gratuita: tiene su propio costo adicional, su propia matriz de dependencias que mantener y sus propios gastos de mantenimiento. Solo resulta útil cuando una referencia estable permite omitir o reutilizar algo costoso.
Los valores que nunca cambian a menudo pueden definirse completamente fuera del componente. En lugar de agrupar varios atributos en un único objeto solo por orden, un hijo puede recibir directamente los valores primitivos individuales. Una función solo debe estabilizarse cuando se utilice en un Effect, una suscripción o dentro de los límites de memorización donde la identidad realmente influya en el comportamiento.
La verdadera prueba está en observar qué ocurre debido a la nueva referencia. Si nada posterior se ve afectado, recrear el valor en cada renderizado no causa daño alguno. Pero si un objeto nuevo provoca la reconstrucción de una tabla grande o hace que se cierre y vuelva a conectarse una suscripción, la identidad de ese objeto pasa a formar parte del contrato de rendimiento del componente y debe tratarse en consecuencia.
4. Almacenar valores calculados y mantenerlos actualizados mediante efectos
Una de las fuentes menos evidentes de renderizado adicional es mantener un estado temporal para algo que podría calcularse simplemente a partir de las props o el estado existentes.
Considere un componente que almacena la lista completa de registros, el texto del filtro actual y, además, una lista filtrada almacenada por separado. O un formulario que registra sus campos, sus errores de validación y también una bandera independiente canSubmit. O un componente de lista que guarda tanto un ID seleccionado como otro valor de estado que contiene el objeto seleccionado correspondiente.
Dado que ese valor duplicado puede desincronizarse, los desarrolladores añaden un Effect para mantenerlo actualizado:
function Products({ products }) {
const [search, setSearch] = useState("");
const [visibleProducts, setVisibleProducts] = useState(products);
useEffect(() => {
setVisibleProducts(
products.filter((product) =>
product.name.toLowerCase().includes(search.toLowerCase())
)
);
}, [products, search]);
// ...
}
Esto es lo que sucede cuando search cambia: el componente se renderiza primero con la lista filtrada obsoleta aún en el estado, React registra esa renderización, luego se dispara el Effect, actualiza el estado y programa otra renderización.
Esa segunda renderización solo existe porque un resultado calculado se almacenó como si fuera un elemento de estado independiente.
Si en su lugar visibleProducts se calcula directamente durante la renderización, permanece automáticamente sincronizado y la renderización adicional desaparece por completo. Cuando la lógica de filtrado es realmente costosa, puedes envolver el cálculo en un memo basado en sus entradas reales. Lo importante es que este tipo de memorización solo almacena un cálculo; nunca debe convertir el resultado almacenado en una segunda fuente de verdad que pueda modificarse de forma independiente.
Este patrón tiene consecuencias más allá de las renderizaciones innecesarias. El estado derivado puede mostrar brevemente un valor que contradice sus entradas, y acumular múltiples efectos juntos puede provocar una cadena de re-renderizaciones en cascada. Al eliminar el estado redundante, generalmente se eliminan también varios efectos de sincronización.
5. Suscribirse a más datos de un almacén externo de lo necesario
Las bibliotecas de estado basadas en selectores pueden proporcionar actualizaciones muy específicas, pero solo si los componentes seleccionan realmente valores estrechos y concretos.
Un atajo frecuente es suscribirse a todo el almacén de estado o a un único objeto grande a nivel de funcionalidad, y luego extraer solo un campo para usarlo. Al hacer esto, el componente se vuelve a renderizar cada vez que cambia algo dentro de ese objeto más amplio, incluso campos que nunca modifica.
Este problema no es exclusivo de las bibliotecas de gestión de estado. También se presenta con los cachés para la obtención de datos, los sistemas basados en observables y los ganchos de suscripción implementados manualmente. Leer desde una fuente grande únicamente por comodidad hace que un componente sea reactivo a muchos más cambios de los necesarios.
La cantidad de estado a la que técnicamente tiene acceso un componente no es lo mismo que la cantidad de ese estado que debería permitirse para que se active su re-renderizado.
6. Claves inestables que causan remontajes en silencio en lugar de actualizaciones
Una nueva renderización ejecuta un componente nuevamente manteniendo intacta su identidad, su estado local y su posición. Un remontaje hace algo diferente: destruye la instancia existente del componente y crea una nueva desde cero. Los remontajes suelen ser mucho más costosos, ya que React ya no puede reutilizar el estado local anterior, las relaciones DOM existentes ni ningún recurso vinculado al ciclo de vida del componente.
Las claves que no son estables pueden convertir silenciosamente lo que deberían ser actualizaciones simples en remontajes repetidos.
Si una clave se deriva de un valor aleatorio, la marca de tiempo actual o un campo que cambia mientras se edita el elemento, React interpreta eso como si el elemento anterior desapareciera por completo. Dado que cada renderización genera una identidad diferente, el hijo anterior se elimina y se monta uno completamente nuevo en su lugar.
A menudo, los síntomas visibles aparecen antes de que alguien se dé cuenta siquiera de un problema de rendimiento. Los campos de texto pierden el foco mientras se escribe, las filas expandidas vuelven a cerrarse, las animaciones comienzan de nuevo desde el principio y el estado de los componentes locales desaparece. El impacto en el rendimiento se vuelve grave cuando los componentes reinstalados requieren una mayor carga de configuración: inicializar editores, gráficos, observadores, suscripciones, reproductores de medios o solicitudes de red.
Usar índices de array como claves crea una versión más sutil de este problema cuando los elementos de la lista pueden reordenarse, insertarse o eliminarse. React puede mantener el estado del componente vinculado a una posición en la lista, aun cuando ahora otro elemento ocupe esa posición. Esa discrepancia puede hacer que el estado se asocie al elemento incorrecto y obligue a realizar trabajos de reconciliación innecesarios, todo porque las claves en realidad no reflejan identidades estables de los elementos.
Una clave bien elegida debe representar la identidad duradera de un elemento dentro de su lista. Solo debería cambiar cuando ese elemento deba considerarse como una instancia completamente diferente, y no simplemente porque se haya producido un renderizado.
Existen casos legítimos en los que cambiar deliberadamente la clave es la herramienta adecuada, ya que obliga a React a restablecer todo un subárbol. Esa debería ser una decisión consciente, ya que implica desechar todo lo que React había preservado anteriormente para ese subárbol. Un remontaje no intencional es mucho más costoso que un renderizado típico, y es fácil pasarlo por alto cuando la expresión de la clave parece inocua a primera vista.
7. Dejar interfaces pesadas montadas cuando están fuera de vista
Un componente no necesita estar visible para seguir funcionando.
Las pestañas, cajones, ventanas modales y paneles plegables suelen permanecer montados pero ocultos visualmente con CSS, ya que este enfoque conserva el estado interno y permite que la interfaz se vuelva a abrir al instante. Para contenido ligero, ese compromiso suele ser razonable.
No obstante, en interfaces más grandes, el hecho de seguir estando montados significa que pueden seguir escuchando el contexto y las fuentes externas, seguir recibiendo nuevos parámetros, seguir volviendo a renderizar, seguir realizando cálculos y mantener activos sus efectos, todo mientras el usuario no tiene forma de ver el resultado. Un panel de control podría mantener varias gráficas completamente activas detrás de pestañas que nadie está mirando en ese momento. Un editor oculto en un panel secreto podría seguir procesando actualizaciones de documentos en segundo plano. Un diálogo que se ha cerrado visualmente aún puede conservar un gran árbol de formularios junto con su lógica de validación.
Tu aplicación paga un costo real por esto, ya que React sigue tratando ese subárbol montado como parte de las rutas de renderizado relevantes. Establecer display: none solo cambia la forma en que el navegador diseña los elementos; no afecta si React sigue siendo responsable de ese subárbol y lo actualiza.
No obstante, desmontar cada elemento de interfaz oculto tampoco es necesariamente la solución adecuada. Reconstruir un editor complejo desde cero cada vez que alguien cambia de pestaña puede resultar más lento en general, y además podría borrar el estado del usuario que debería persistir. Lo importante es tomar una decisión deliberada basada en el ciclo de vida del componente y en cuán costoso es realmente.
Una opción es retrasar el montaje de un panel pesado hasta que se active por primera vez. Aquellos elementos que realmente necesitan sobrevivir a una desmontaje visual pueden permanecer en un contenedor más pequeño y dedicado, mientras que el árbol renderizado en sí desaparece. Algunos productos encuentran un término medio manteniendo montados solo los paneles más recientemente utilizados y liberando el resto.
La verdadera pregunta no es si algo está actualmente en la pantalla, sino si aún debe recibir y reaccionar a las actualizaciones mientras está fuera de la pantalla.
8. Permitir que los efectos se reinicien debido a dependencias inestables
Un efecto que depende de algún valor se limpiará y se ejecutará nuevamente cada vez que ese valor cambie, y eso es exactamente correcto cuando la relación externa que representa el efecto ha cambiado realmente.
El problema es que las dependencias inestables pueden hacer que parezca que esa relación cambia en cada renderizado, incluso cuando no ocurre nada significativo.
Considere un componente que crea un objeto de opciones y se lo pasa a un Hook personalizado, el cual luego utiliza ese objeto como dependencia de un Effect. Dado que el componente padre crea un objeto completamente nuevo en cada renderizado, el Effect sigue interrumpiendo su conexión y volviéndola a establecer. De manera similar, un manejador de eventos recreado en cada renderizado y utilizado como dependencia para un efecto de escucha hará que ese escuchador sea eliminado y vuelva a conectarse una y otra vez. Un objeto de configuración de consulta puede generar una solicitud nueva cada vez, aunque ninguno de sus campos significativos haya cambiado realmente.
Este tipo de sobrecarga no siempre se manifiesta como un problema de renderizado. En su lugar, se presenta como solicitudes de red duplicadas, conexiones que se abren y cierran rápidamente, una interfaz de usuario que parpadea, o listeners de eventos que desaparecen y reaparecen. Peor aún, estos reinicios del Effect suelen llamar a los propios configuradores de estado, lo que provoca más renders y oculta la causa original del problema.
La solución no consiste en eliminar las dependencias del array solo para silenciar las advertencias. Las dependencias existen para describir con precisión qué valores lee el Effect, y ocultar una de ellas genera cierres obsoletos y una sincronización defectuosa.
La mejor opción es asignar una identidad estable a la dependencia cuando esa identidad sea lo que realmente importa. Dependiendo de la situación, podrías construir el objeto dentro del propio Effect a partir de valores primitivos, estabilizar la función de callback para que no se reconstruya en cada renderizado, o darte cuenta de que la lógica en realidad no pertenece a un Effect y trasladarla a un manejador de eventos en su lugar.
Un Effect debería reiniciarse porque el sistema externo realmente requiere una nueva sincronización, y no solo porque un renderizado haya producido otro objeto funcionalmente idéntico.
9. Sustituir el estado por un nuevo objeto que represente lo mismo
Dependiendo de cómo compara React los valores, a veces puede omitir la vuelta a renderizar cuando el estado siguiente es igual al actual. Pero en el caso de objetos y arrays, proporcionarle a React una referencia nueva hace que el estado parezca diferente incluso cuando su contenido real indica lo mismo.
Este patrón aparece con frecuencia en los manejadores de eventos y la lógica de almacenamiento. Un filtro se reinicia con un objeto completamente nuevo que en realidad es equivalente a aquel que ya existía. Un reducer devuelve un objeto de estado recién creado para una acción que no cambió nada. Una respuesta obtenida mediante polling se copia en un nuevo array aunque cada elemento dentro de él permanezca sin cambios.
React no cuenta con una forma general para detectar la igualdad semántica profunda por sí mismo. Si quien gestiona el estado devuelve un objeto nuevo, React lo considera una actualización lo suficientemente significativa como para procesarla.
Para evitar esto, los reducers deben devolver el objeto de estado existente cuando una acción en realidad no cambia nada. Los configuradores de estado pueden comparar los valores relevantes antes de reemplazar todo un objeto estructurado. Las capas encargadas de normalizar los datos recibidos pueden mantener las mismas referencias para los registros que no han cambiado, en lugar de reconstruir toda la colección desde cero después de cada respuesta.
Nada de esto implica que debas realizar una comparación profunda en cada actualización de estado. La comparación profunda puede terminar costando más que lo que intenta evitar, y además introduce otra noción de “igualdad” que ahora debes mantener de forma consistente. Un enfoque más sostenible es preservar la identidad del objeto de forma natural siempre que los datos subyacentes realmente no hayan cambiado.
El compartir estructural es lo que permite a React, junto con todos los consumidores memorizados en la cadena de procesamiento, distinguir un cambio real de una copia repetida de la misma información. Una nueva referencia debería indicar un nuevo estado, y no solo una nueva asignación de memoria que almacene los mismos valores.
El objetivo no es eliminar las actualizaciones de renderizado
Las aplicaciones React no se vuelven rápidas porque se nieguen a renderizarse. Se vuelven rápidas porque el proceso de renderizado detecta cambios significativos y se limita a las partes del árbol que realmente necesitan reconsiderar su salida.
Volver a renderizar un componente pequeño y simple suele ser más económico que crear y mantener un complejo sistema de memorización alrededor de él. Una actualización de contexto que solo afecte a unos pocos consumidores ligeros podría no justificar en absoluto ninguna modificación arquitectónica. Las decisiones relacionadas con el rendimiento deben basarse en el costo real de la interacción, en análisis de rendimiento y en la escala que realmente se espera, y no en un instinto generalizado de evitar el rendering siempre que sea posible.
Los hábitos descritos anteriormente son riesgosos precisamente porque amplían silenciosamente el alcance de lo que se actualiza. Elevar demasiado el estado convierte una interacción local y pequeña en tareas que abarcan toda la página. Un contexto amplio y las suscripciones al almacenamiento difunden cambios no relacionados a componentes que no necesitan conocerlos. Las referencias recién creadas hacen que las entradas estables se vean diferentes de una renderización a otra. El estado derivado combinado con Effects puede desencadenar cadenas de actualizaciones adicionales. Las claves inestables impiden la reutilización en favor de montajes completos desde cero. Los subárboles ocultos siguen realizando tareas que nadie puede ver, y el estado estructuralmente idéntico sigue siendo tratado como completamente nuevo.
La memorización puede ser realmente útil una vez que se comprendan adecuadamente estas relaciones subyacentes. Funciona mejor cuando los límites de tus componentes, la propiedad del estado y las identidades de referencia ya tienen sentido por sí solos. Sin esa base, la memorización se convierte simplemente en una capa defensiva alrededor de un grafo de actualizaciones que sigue siendo innecesariamente grande.
La pregunta más útil sobre el rendimiento de React no es “¿cómo evito que este componente se renderice?”
Sino “¿qué cambió realmente, quién es el responsable de ese cambio y por qué se le pidió a este componente que volviera a reconsiderarse?”
Una vez que tengas respuestas claras a esas preguntas, una gran parte de las actualizaciones innecesarias tiende a desaparecer por sí sola, sin convertir tu árbol de componentes en un enredo de cachés y funciones de comparación.
Lecturas relacionadas
- Patrones de Diseño React: Desde el OOP Clásico hasta los Gancho Modernos — Explica cómo los patrones de software clásicos como Singleton, Factory y Observer se aplican en React, junto con patrones específicos de React como HOCs, Hooks y Componentes Compuestos.
- React 19.2 Explicado: Activity, useEffectEvent y Renderizado Estático — Aprenda cómo el nuevo componente Activity, el hook useEffectEvent y el renderizado estático parcial de React 19.2 solucionan los costos ocultos de rendimiento en las interfaces de usuario modernas.