Haga menos trabajo primero: Una lista de verificación para el rendimiento en React antes de la memorización
Reduce los costos de la aplicación React evitando trabajo innecesario: usa debounce en las búsquedas, implementa paginación, gestiona el estado de forma adecuada, utiliza claves estables, lleva el procesamiento al lado del servidor y carga los elementos de forma perezosa; luego, memoriza si es necesario.
Cuando una aplicación React parece lenta, la reacción instintiva es recurrir a useMemo, useCallback o React.memo. Estas herramientas reducen el esfuerzo necesario para realizar tareas existentes, pero en muchas aplicaciones el verdadero costo proviene de operaciones que nunca deberían haberse ejecutado: solicitudes redundantes, cargas excesivas, actualizaciones de estado que afectan a gran parte de la estructura de la aplicación, y código que los usuarios aún no han solicitado. Esta guía explica siete formas de eliminar esas tareas antes de optimizar lo que queda, junto con una lista de verificación que se puede utilizar durante las revisiones de código.
La pregunta guía en todo momento es simple: ¿se puede evitar completamente esta tarea?
Realizar menos solicitudes: desactivar temporalmente la búsqueda al escribir
Los campos de búsqueda son una fuente clásica de solicitudes innecesarias. Un manejo ingenuo llama a la API con cada cambio:
const handleSearch = (value) => {
fetchUsers(value);
};
Escribir la palabra “React” en ese campo genera una solicitud por cada tecla pulsada:
R
Re
Rea
Reac
React
Cinco idas y venidas para una sola búsqueda, cuatro de las cuales devuelven resultados que nadie verá. Un enfoque mejor es esperar a que el usuario haga una pausa breve y solo entonces enviar la consulta. Eso es lo que hace el debouncing: cada nueva llamada reinicia un temporizador, y la función envuelta se ejecuta únicamente cuando el temporizador vence sin interrupciones.
const handleSearch = debounce((value) => {
fetchUsers(value);
}, 300);
Con un período de 300 ms, los usuarios que escriben rápido generan una sola solicitud al final. De este modo se ahorra tráfico de red, carga en el servidor y procesamiento por parte del cliente de respuestas descartadas. Cabe señalar que nada aquí afecta a la renderización; el beneficio proviene exclusivamente de no realizar ese trabajo.
Una precaución al usar un ayudante como este dentro de un componente es que si se llama directamente a debounce(...) en el cuerpo del componente, se crea una nueva función con efecto de atenuación (y un nuevo temporizador) en cada renderizado, lo que invalida el funcionamiento del mismo. Es mejor crearla una sola vez, por ejemplo con useMemo o useRef, o bien utilizar el patrón basado en efectos que se muestra a continuación.
Un componente de búsqueda con atenuación autocontenido
La misma idea puede implementarse únicamente con las primitivas de React, sin necesidad de bibliotecas auxiliares. Comience con las importaciones y dos valores de estado: el texto de entrada actual y los usuarios obtenidos.
import { useEffect, useState } from "react";
function UserSearch() {
const [search, setSearch] = useState("");
const [users, setUsers] = useState([]);
Un efecto se ejecuta cada vez que cambia search. En lugar de realizar la consulta de inmediato, programa su ejecución con setTimeout. Dentro del callback, una consulta vacía o compuesta únicamente por espacios en blanco elimina los resultados y termina el proceso sin realizar ninguna solicitud.
useEffect(() => {
const timer = setTimeout(async () => {
if (!search.trim()) {
setUsers([]);
return;
}
En una consulta real, la función de callback solicita los usuarios que coinciden, codificando el término de búsqueda para que los caracteres especiales no dañen la URL:
const response = await fetch(
`/api/users?search=${encodeURIComponent(search)}`
);
Luego analiza el JSON y almacena el resultado, todo ello dentro del temporizador de 300 ms:
const data = await response.json();
setUsers(data);
}, 300);
La función de limpieza es donde realmente tiene lugar el debounce. React la ejecuta antes de que se vuelva a ejecutar el efecto, por lo que cada tecla presionada cancela el temporizador pendiente anterior:
return () => clearTimeout(timer);
}, [search]);
Finalmente, el componente muestra un campo de entrada controlado vinculado a search:
return (
<div>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search users..."
/>
Y enumera a los usuarios, identificados por sus IDs:
{users.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
Cada carácter introducido reinicia el temporizador, y la solicitud se envía únicamente después de 300 ms de silencio. Tenga en cuenta que el suavizado reduce la cantidad de solicitudes enviadas, pero no garantiza que se completen en orden; una respuesta anterior lenta aún puede sobrescribir a una más reciente. Si esto es importante para su interfaz de usuario, combínelo con la cancelación de solicitudes, como se explica en arreglando condiciones de carrera: el suavizado no puede solucionarlo en interfaces de búsqueda.
La lección general: prevenir el trabajo suele ser mejor que hacer que el trabajo existente sea más rápido.
Obtenga solo los datos que necesita la pantalla
Otra causa frecuente de consumo excesivo es descargar mucho más datos de lo que se muestra. Supongamos que un endpoint devuelve 10,000 usuarios mientras la vista solo muestra 20 a la vez. El navegador aún debe descargarlos, analizarlos y almacenarlos todos en memoria, y React tiene que trabajar con arrays mucho más grandes de lo necesario.
Cuando el caso de uso lo permita, use paginación para que cada solicitud contenga solo una página:
API
↓
20 users
↓
Browser
↓
Display
Cada vez que el usuario avanza, solicite el siguiente fragmento de datos. Esto reduce el uso de la red, el consumo de memoria, el procesamiento en el lado del cliente y el volumen de datos que fluye a través de sus componentes. El desplazamiento infinito y las API basadas en cursor siguen el mismo principio. Cambiar la forma en que se obtienen los datos suele tener un efecto mucho mayor que ajustar cualquier componente por separado.
Mantenga el estado que cambia rápidamente cerca de sus consumidores
El lugar donde se almacena el estado determina cuánto de los elementos del árbol se vuelve a renderizar cuando este cambia. Considere un panel de control que gestiona el texto de búsqueda:
function Dashboard() {
const [search, setSearch] = useState("");
return (
<>
<SearchBox value={search} onChange={setSearch} />
<Analytics />
<UserTable />
</>
);
}
Cada tecla pulsada actualiza Dashboard, por lo que Analytics y UserTable también se vuelven a renderizar, aunque ninguno de ellos utiliza el valor de búsqueda. En un panel de control grande, esto suma efectos negativos. Si el estado solo es relevante para una pequeña parte de la interfaz, moverlo a esa parte (en este caso, al propio SearchBox) limita las actualizaciones a donde son necesarias y hace que la estructura del componente sea más fácil de seguir.
Esto no es una regla que exija siempre transferir el estado hacia niveles inferiores. Cuando varios componentes realmente necesitan el mismo valor, su padre común más cercano es el propietario adecuado. El objetivo es mantener el estado en el nivel más bajo que siga siendo compartido por todos los componentes que realmente lo consultan.
Proporcionar claves estables a los elementos de lista dinámicos
Las listas son donde los pequeños detalles tienen efectos desproporcionados. Un patrón común utiliza el índice del array como clave:
{users.map((user, index) => (
<UserCard key={index} user={user} />
))}
Las claves basadas en índices no siempre son incorrectas. Para una lista estática cuyo orden nunca cambia, funcionan bien. En el caso de una lista dinámica, un ID estable proveniente de los datos le otorga a cada elemento una identidad duradera:
{users.map((user) => (
<UserCard key={user.id} user={user} />
))}
La diferencia radica en lo que representa la clave:
index → position
id → identity
Si los elementos pueden añadirse, eliminarse, reordenarse o filtrarse, las posiciones cambian y React asocia los elementos incorrectos: puede volver a renderizar más de lo necesario, y peor aún, puede vincular el estado interno de un elemento al de otro. Esto se convierte en un error visible cuando los elementos de la lista contienen campos de entrada, estado local u otros elementos interactivos, como un campo de texto que mantiene su valor escrito mientras la fila debajo cambia. Prefiera un ID estable siempre que la lista pueda modificarse.
Cuestione el proceso, no solo su velocidad
Las transformaciones intensivas en el lado del cliente son otra fuente de costos evitables. Aquí, los usuarios se filtran, ordenan y mapean a nuevos objetos:
const filteredUsers = users
.filter((user) => user.isActive)
.sort((a, b) => a.name.localeCompare(b.name))
.map((user) => ({
...user,
displayName: user.name.toUpperCase()
}));
Con unos pocos miles de registros, repetir este proceso en cada renderizado resulta costoso. Una opción es memorizar los resultados, pero primero hay que preguntarse si el cliente realmente necesita realizar esta tarea. A menudo, el endpoint puede filtrar por sí mismo los usuarios activos, la base de datos puede manejar el ordenamiento gracias a los índices, lo que lo hace económico, y la paginación puede reducir el conjunto de datos para que el procesamiento restante sea sencillo. Reducir la cantidad de datos de entrada suele ser más efectivo que acelerar el cálculo.
Cargar funciones según sea necesario
Las aplicaciones grandes incluyen funcionalidades que muchos usuarios nunca abren en una sesión determinada. Por ejemplo, una página de informes puede estar completamente separada del panel principal. En lugar de incluirla en la descarga inicial, cárguela de forma perezosa:
const Reports = lazy(() => import("./Reports"));
Con React.lazy, el módulo se carga la primera vez que se renderiza el componente, lo cual debe ocurrir dentro de un límite Suspense que muestra un contenido alternativo mientras se carga. Esto resulta más beneficioso en aplicaciones con muchas rutas, grandes áreas funcionales, dependencias pesadas como bibliotecas de gráficos o editores, o pantallas que pocos usuarios visitan.
Debes tener claro qué te aporta: una carga inicial de JavaScript más pequeña y una primera carga más rápida. No hace que el código dentro del componente lazy se ejecute más rápido una vez cargado, y añade un breve retraso de carga la primera vez que se abre esa función.
Memoizar con un motivo
Un error común es aplicar APIs de optimización por defecto en todo el código, como envolver cada manejador:
const handleClick = useCallback(() => {
setSelectedUser(id);
}, [id]);
O cada valor derivado:
const data = useMemo(() => {
return processData(users);
}, [users]);
Estos ganchos tienen usos legítimos, pero cada uno añade código, arrays de dependencias que deben mantenerse correctos y su propio pequeño costo en tiempo de ejecución. Antes de agregar uno, verifica:
- ¿Es el cálculo realmente costoso?
- ¿Se ejecuta con frecuencia?
- ¿Se reutiliza el resultado en diferentes renders?
- ¿Un hijo memorizado o un efecto depende de una referencia estable?
- ¿El cambio generará una diferencia medible?
Si las respuestas son en su mayoría “no”, el código más simple es el mejor. El rendimiento proviene de la optimización adecuada en el lugar correcto, no de la cantidad de código de optimización. El React Compiler, dondequiera que se haya adoptado, automatiza gran parte de esta memorización, lo cual es otra razón para no escribirla a mano en todas partes; consulte qué optimiza el React Compiler y qué deja para usted.
Lista de verificación para la revisión
Revise estas preguntas al auditar una funcionalidad de React:
- Solicitudes innecesarias? Busque llamadas por cada tecla presionada, solicitudes duplicadas y búsquedas de datos que actualmente no se muestran.
- Demasiados datos? Use paginación, filtre en el servidor y posponga las búsquedas hasta que se necesiten los datos.
useMemo, useCallback o React.memo solo porque existen.El rendimiento es un proceso en cadena, no solo una renderización
El rendimiento de React no se refiere únicamente a React. Los costos se acumulan a lo largo de toda la cadena de operaciones:
API calls
↓
Amount of data
↓
State updates
↓
Component structure
↓
Data processing
↓
Rendering
↓
Bundle size
Centrarse únicamente en la capa de renderizado puede ocultar un problema mucho más grave en las etapas anteriores del proceso. Reducir unos pocos milisegundos en el renderizado sirve de poco si la página sigue realizando solicitudes redundantes, y memorizar un componente no ayuda cuando se están descargando miles de registros que nunca se muestran. Comience desde la parte superior de la cadena y avance hacia abajo.
Puntos clave
- Eliminar tareas (solicitudes, bytes, actualizaciones, código) suele dar mejores resultados que acelerar dichas tareas.
- Establezca un retraso en las solicitudes generadas por entradas del usuario, y combine ese retraso con la cancelación cuando sea importante el orden de las solicitudes.
- Deje que el servidor filtre, ordene y pagine los datos; envíe al cliente solo lo que se va a mostrar.
- Coloque el estado en el nivel más bajo que sirva a todos sus lectores, y ordene las listas dinámicas por identidad.
- Utilice la carga diferida para lograr una primera carga más ligera, y recurra a la memorización solo cuando un costo concreto lo justifique.
Lecturas relacionadas
- Qué optimiza el compilador de React y qué deja en su cargo — Entienda qué tareas de rendimiento automatiza el compilador de React, por qué las APIs lentas y los paquetes pesados siguen siendo su responsabilidad, y cómo adoptarlo de forma segura en una base de código React existente.
- Diagnóstico de problemas de rendimiento en React más allá del tiempo de respuesta de la API — Aprenda por qué las APIs rápidas no garantizan interfaces de usuario ágiles, y cómo el renderizado, el tamaño del paquete y la organización de los archivos influyen silenciosamente en el verdadero rendimiento de una aplicación React.
- Mantener la página responsive: cuándo mover el trabajo de la CPU a un web worker — Aprenda por qué los bucles intensivos congelan la interfaz del navegador, cómo el patrón de mensajes de Web Worker descarga ese trabajo y cómo decidir si una tarea realmente merece un worker.
- Budgets de rendimiento para JavaScript: aplicar límites en el bundle en CI — Entienda por qué JavaScript cuesta mucho más que su tiempo de descarga, cómo definir un presupuesto de rendimiento realista y cómo hacer que webpack rechace las compilaciones que lo excedan.
- JavaScript legible con ejemplos: diez refactores antes y después — Diez pequeños refactores en JavaScript y React, que van desde la nomenclatura y los objetos de parámetros hasta el manejo de errores y el formato, para que el código sea más fácil de leer y modificar por el siguiente desarrollador.
- Por qué React.lazy() necesita un export por umbrales y cómo cargar los con nombre — Entienda qué recibe React.lazy() de una importación dinámica, por qué los exports con nombre lo dañan, cómo remapearlos al export por umbrales y cómo encajan Suspense y el dividir código.
- Diseñando frontends Offline-First como réplicas: colas, reintentos, conflictos — Aprenda por qué el enfoque offline-first convierte al navegador en una réplica de datos, y cómo las memorias locales, las colas de mutaciones duraderas, los reintentos idempotentes y las reglas de conflicto funcionan en conjunto.