React es rápido por defecto para la mayoría de aplicaciones. Pero a medida que tu aplicación crece, con más componentes, más estado compartido y más datos que renderizar, empiezan a aparecer los síntomas clásicos: la interfaz se siente lenta al escribir en un input, las listas tardan en desplazarse, los cambios de pestaña tardan en reflejarse. Optimizar el rendimiento de React no es una ciencia oscura, pero requiere entender exactamente qué hace que un componente se vuelva a renderizar y cuándo eso es realmente un problema.
En esta guía vas a aprender a diagnosticar problemas de rendimiento reales, las técnicas que de verdad funcionan, y los errores de optimización prematura que hacen el código más complejo sin aportar ninguna mejora medible.
Antes de optimizar: mide, no adivines
El primer error al optimizar React es hacerlo a ciegas, basándose en intuiciones sobre qué "debería" ser lento. La regla de oro de cualquier optimización de rendimiento es la misma en cualquier lenguaje: mide primero, optimiza después.
React DevTools Profiler
// Instalar la extensión React DevTools en Chrome/Firefox
// Pestaña "Profiler" → botón de grabación → interactuar con la app → detener
// El Profiler muestra:
// - Qué componentes se renderizaron en cada commit
// - Cuánto tiempo tardó cada uno
// - Por qué se renderizó (qué prop o estado cambió)
// Activar "Record why each component rendered" en la configuración
// del Profiler para ver la causa exacta de cada render
Medir renders con un hook personalizado
import { useEffect, useRef } from 'react';
function useContadorRenders(nombreComponente) {
const renders = useRef(0);
useEffect(() => {
renders.current += 1;
console.log(`${nombreComponente} se ha renderizado ${renders.current} veces`);
});
}
function ListaProductos({ productos }) {
useContadorRenders('ListaProductos');
return (
{p.nombre}
);
}
El Performance tab del navegador
// Para medir el impacto real en el usuario (no solo en React):
// Chrome DevTools → Performance → Record → interactuar → Stop
// Busca "Long Tasks" (en rojo): tareas que bloquean el hilo principal más de 50ms
// y correlaciónalas con los componentes que se están renderizando
Por qué se re-renderiza un componente
Antes de optimizar nada, hay que entender las tres razones por las que React vuelve a renderizar un componente:
- Su estado cambia (
useState,useReducer). - Sus props cambian.
- Su componente padre se re-renderiza (por defecto, todos los hijos se vuelven a renderizar aunque sus props no hayan cambiado).
El tercer punto es la causa más común de renders innecesarios y la que sorprende a más desarrolladores.
function App() {
const [contador, setContador] = useState(0);
return (
setContador(c => c + 1)}>
Incrementar: {contador}
{/* Este componente se re-renderiza CADA VEZ que contador cambia,
aunque ComponenteCostoso no use 'contador' en absoluto */}
);
}
function ComponenteCostoso() {
console.log('ComponenteCostoso renderizado'); // se ejecuta en cada click
// Imagina un cálculo pesado o una lista larga aquí
return Soy costoso de renderizar;
}
React.memo: evitar renders cuando las props no cambian
React.memo envuelve un componente para que React compare sus props superficialmente antes de re-renderizarlo. Si las props son iguales a las del render anterior, React reutiliza el resultado anterior sin volver a ejecutar el componente.
import { memo } from 'react';
const ComponenteCostoso = memo(function ComponenteCostoso() {
console.log('ComponenteCostoso renderizado');
return Soy costoso de renderizar;
});
function App() {
const [contador, setContador] = useState(0);
return (
setContador(c => c + 1)}>
Incrementar: {contador}
{/* ahora NO se re-renderiza al cambiar contador */}
);
}
El problema de memo con props que son objetos o funciones
memo solo ayuda si las props no cambian de referencia entre renders. Los objetos, arrays y funciones se recrean en cada render por defecto en JavaScript, lo que invalida memo silenciosamente.
// ❌ memo no ayuda aquí: 'config' y 'onClick' son nuevos en cada render del padre
function App() {
const [contador, setContador] = useState(0);
return (
setContador(c => c + 1)}>+1: {contador}
console.log('click')} // ← nueva función en cada render
/>
);
}
const ComponenteHijo = memo(function ComponenteHijo({ config, onClick }) {
console.log('ComponenteHijo renderizado'); // se ejecuta en CADA render del padre
return Click ({config.tema});
});
// ✅ Solución: estabilizar las referencias con useMemo y useCallback
function App() {
const [contador, setContador] = useState(0);
const config = useMemo(() => ({ tema: 'oscuro' }), []); // misma referencia siempre
const manejarClick = useCallback(() => console.log('click'), []);
return (
setContador(c => c + 1)}>+1: {contador}
{/* Ahora ComponenteHijo NO se re-renderiza al cambiar contador */}
);
}
useMemo: memorizar el resultado de un cálculo
useMemo evita recalcular un valor costoso en cada render, a menos que sus dependencias cambien.
import { useMemo, useState } from 'react';
function ListaConFiltro({ productos }) {
const [busqueda, setBusqueda] = useState('');
const [tema, setTema] = useState('claro'); // no relacionado con el filtrado
// ❌ Sin useMemo: se recalcula en CADA render, incluso al cambiar 'tema'
const productosFiltrados = productos
.filter(p => p.nombre.toLowerCase().includes(busqueda.toLowerCase()))
.sort((a, b) => a.precio - b.precio);
// ✅ Con useMemo: solo se recalcula cuando 'productos' o 'busqueda' cambian
const productosFiltradosMemo = useMemo(() => {
console.log('Recalculando filtrado y ordenación...');
return productos
.filter(p => p.nombre.toLowerCase().includes(busqueda.toLowerCase()))
.sort((a, b) => a.precio - b.precio);
}, [productos, busqueda]); // se ejecuta solo si estas dependencias cambian
return (
setBusqueda(e.target.value)} />
setTema(t => t === 'claro' ? 'oscuro' : 'claro')}>
Cambiar tema (no afecta al filtrado)
{p.nombre} - {p.precio}€
);
}
El criterio para usar useMemo: cuando el cálculo es genuinamente costoso (filtrar/ordenar listas grandes, transformaciones de datos complejas) y el componente se re-renderiza por razones ajenas a ese cálculo. Para cálculos triviales (sumar dos números, concatenar strings cortos), useMemo añade overhead sin beneficio.
useCallback: memorizar funciones
useCallback es el equivalente de useMemo pero específico para funciones. Es necesario principalmente en dos casos: pasar callbacks a componentes memorizados con memo, y usar una función como dependencia de otro hook.
import { useCallback, useState, memo } from 'react';
const BotonHijo = memo(function BotonHijo({ onClick, etiqueta }) {
console.log(`BotonHijo "${etiqueta}" renderizado`);
return {etiqueta};
});
function Formulario() {
const [nombre, setNombre] = useState('');
const [contadorClicks, setContadorClicks] = useState(0);
// ❌ Sin useCallback: nueva función en cada render → BotonHijo se re-renderiza siempre
const manejarGuardar = () => {
console.log('Guardando:', nombre);
};
// ✅ Con useCallback: misma referencia mientras 'nombre' no cambie
const manejarGuardarMemo = useCallback(() => {
console.log('Guardando:', nombre);
}, [nombre]); // se recrea solo si 'nombre' cambia
return (
setNombre(e.target.value)} />
setContadorClicks(c => c + 1)}>
Clicks no relacionados: {contadorClicks}
{/* No se re-renderiza al hacer click en "Clicks no relacionados" */}
);
}
Un error común es envolver todas las funciones en useCallback "por si acaso". Si la función no se pasa a un componente memorizado ni se usa como dependencia en otro hook, useCallback no aporta ningún beneficio y añade complejidad innecesaria.
El problema de los componentes monolíticos
Una técnica de optimización subestimada es simplemente dividir componentes grandes en componentes más pequeños. Cuando un componente gestiona estado que solo afecta a una parte pequeña de su JSX, todo el árbol se re-renderiza innecesariamente.
// ❌ Un solo componente grande: cambiar 'busqueda' re-renderiza TODO, incluyendo
// la tabla de estadísticas que no depende de la búsqueda
function Dashboard() {
const [busqueda, setBusqueda] = useState('');
const [usuarios, setUsuarios] = useState([...]);
const estadisticas = calcularEstadisticasCostosas(usuarios); // costoso
return (
setBusqueda(e.target.value)} />
{/* Esta tabla se re-renderiza en CADA tecla escrita en el input,
aunque no use 'busqueda' para nada */}
u.nombre.includes(busqueda))}
/>
);
}
// ✅ Dividido: el estado de búsqueda vive en un componente más pequeño y aislado
function Dashboard() {
const [usuarios, setUsuarios] = useState([...]);
const estadisticas = useMemo(() => calcularEstadisticasCostosas(usuarios), [usuarios]);
return (
{/* su propio estado, aislado */}
{/* no se re-renderiza por la búsqueda */}
);
}
function BuscadorUsuarios({ usuarios }) {
const [busqueda, setBusqueda] = useState('');
const usuariosFiltrados = usuarios.filter(u => u.nombre.includes(busqueda));
return (
setBusqueda(e.target.value)} />
);
}
El patrón children como optimización
Cuando pasas componentes como children, React no re-renderiza esos children si su contenido no ha cambiado, incluso si el componente que los envuelve se re-renderiza.
// El estado 'abierto' vive en Modal, pero los children no se re-renderizan con él
function Modal({ children, abierto }) {
if (!abierto) return null;
return (
{children}
);
}
function App() {
const [modalAbierto, setModalAbierto] = useState(false);
return (
setModalAbierto(true)}>Abrir modal
{/* ComponenteCostoso se crea UNA VEZ en App, no en cada render de Modal */}
);
}
Listas largas: virtualización
Renderizar miles de elementos en el DOM, aunque cada uno sea simple, es costoso. La virtualización (o windowing) renderiza solo los elementos visibles en el viewport, no la lista completa.
// npm install react-window
import { FixedSizeList } from 'react-window';
function ListaVirtualizada({ items }) {
const Fila = ({ index, style }) => (
{items[index].nombre}
);
return (
{Fila}
);
}
// Solo se renderizan los ~12-15 elementos visibles en pantalla a la vez,
// independientemente de si la lista tiene 100 o 100.000 elementos
Alternativa moderna: @tanstack/react-virtual
// npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
function ListaVirtualizadaModerna({ items }) {
const contenedorRef = useRef(null);
const virtualizador = useVirtualizer({
count: items.length,
getScrollElement: () => contenedorRef.current,
estimateSize: () => 50,
});
return (
{virtualizador.getVirtualItems().map((virtualItem) => (
{items[virtualItem.index].nombre}
))}
);
}
Lazy loading de componentes y code splitting
No toda la aplicación necesita cargarse de golpe. React.lazy y Suspense permiten dividir el bundle de JavaScript en partes que se cargan solo cuando se necesitan, reduciendo el tiempo de carga inicial.
import { lazy, Suspense } from 'react';
// El componente solo se descarga cuando se renderiza por primera vez
const PanelAdmin = lazy(() => import('./PanelAdmin'));
const Configuracion = lazy(() => import('./Configuracion'));
const GraficosAvanzados = lazy(() => import('./GraficosAvanzados'));
function App() {
const [vistaActual, setVistaActual] = useState('inicio');
return (
setVistaActual('admin')}>Admin
setVistaActual('config')}>Configuración
Cargando...}>
{vistaActual === 'admin' && }
{vistaActual === 'config' && }
);
}
// El usuario que solo visita "inicio" nunca descarga el código
// de PanelAdmin, Configuracion ni GraficosAvanzados
Lazy loading basado en rutas (el caso más habitual)
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const Inicio = lazy(() => import('./paginas/Inicio'));
const Dashboard = lazy(() => import('./paginas/Dashboard'));
const Configuracion = lazy(() => import('./paginas/Configuracion'));
function App() {
return (
}>
} />
} />
} />
);
}
// Cada ruta genera su propio chunk de JavaScript, descargado solo cuando se visita
Evitar crear objetos y arrays en el render
// ❌ Crea un nuevo array en cada render, rompe la comparación de memo en los hijos
function Lista() {
return (
);
}
// ✅ Mover fuera del componente si el valor no depende de props o estado
const CATEGORIAS = ['electronica', 'ropa', 'hogar'];
function Lista() {
return ;
}
// ✅ O usar useMemo si depende de algo que cambia poco
function Lista({ tipoNegocio }) {
const categorias = useMemo(
() => obtenerCategoriasPara(tipoNegocio),
[tipoNegocio]
);
return ;
}
Optimizar el contexto: el problema oculto de Context API
Cuando un valor del Context cambia, todos los componentes que consumen ese contexto se re-renderizan, sin importar si usan la parte del valor que cambió.
// ❌ Un solo contexto con todo mezclado: cualquier cambio re-renderiza a todos
const AppContext = createContext();
function AppProvider({ children }) {
const [usuario, setUsuario] = useState(null);
const [tema, setTema] = useState('claro');
const [notificaciones, setNotificaciones] = useState([]);
// Este objeto es NUEVO en cada render → todos los consumidores se re-renderizan siempre
const value = { usuario, setUsuario, tema, setTema, notificaciones, setNotificaciones };
return {children};
}
// ✅ Separar contextos por dominio + memorizar el valor
const UsuarioContext = createContext();
const TemaContext = createContext();
const NotificacionesContext = createContext();
function AppProvider({ children }) {
const [usuario, setUsuario] = useState(null);
const [tema, setTema] = useState('claro');
const [notificaciones, setNotificaciones] = useState([]);
const valorUsuario = useMemo(() => ({ usuario, setUsuario }), [usuario]);
const valorTema = useMemo(() => ({ tema, setTema }), [tema]);
const valorNotif = useMemo(() => ({ notificaciones, setNotificaciones }), [notificaciones]);
return (
{children}
);
}
// Un componente que solo usa TemaContext no se re-renderiza
// cuando cambian las notificaciones o el usuario
Debounce y throttle en inputs y eventos frecuentes
import { useState, useEffect, useMemo } from 'react';
function BuscadorProductos() {
const [busqueda, setBusqueda] = useState('');
const [resultados, setResultados] = useState([]);
// Sin debounce: se hace una petición a la API en cada tecla pulsada
// Con debounce: solo se hace la petición 300ms después de que el usuario pare de escribir
useEffect(() => {
if (!busqueda) {
setResultados([]);
return;
}
const temporizador = setTimeout(async () => {
const res = await fetch(`/api/buscar?q=${busqueda}`);
setResultados(await res.json());
}, 300);
return () => clearTimeout(temporizador); // cancelar si el usuario sigue escribiendo
}, [busqueda]);
return (
setBusqueda(e.target.value)} />
{r.nombre}
);
}
El error más común: optimizar antes de tiempo
Es tentador envolver cada componente en memo y cada función en useCallback desde el principio del proyecto. Esto es contraproducente: memo y useCallback tienen un coste de comparación y de memoria. Si los renders no son realmente costosos, el overhead de memorizar puede ser mayor que el coste de simplemente re-renderizar.
La estrategia correcta es: escribe código simple primero, mide cuando notes lentitud real, y optimiza específicamente los componentes que el Profiler identifica como costosos. La mayoría de componentes de una aplicación típica no necesitan ninguna optimización manual.
Checklist de optimización por orden de impacto
- Mide con React DevTools Profiler antes de cambiar nada.
- Divide componentes grandes para aislar el estado que cambia frecuentemente.
- Virtualiza listas largas (más de ~100 elementos visibles potencialmente).
- Aplica code splitting por rutas con
lazyySuspense. - Separa el Context API por dominio si tienes un contexto con mucho contenido no relacionado.
- Usa
memoen componentes específicos que el Profiler identifique como costosos y que reciben las mismas props frecuentemente. - Usa
useMemo/useCallbacksolo donde el cálculo es genuinamente costoso o donde es necesario para quememofuncione. - Añade debounce a inputs que disparan peticiones de red o cálculos costosos.
No hay comentarios todavía. Sé el primero en compartir tu opinión.