Cómo optimizar el rendimiento de una app React

D
DanisCh
• 11 min de lectura
Cómo optimizar el rendimiento de una app React
JavaScript

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:

  1. Su estado cambia (useState, useReducer).
  2. Sus props cambian.
  3. 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

  1. Mide con React DevTools Profiler antes de cambiar nada.
  2. Divide componentes grandes para aislar el estado que cambia frecuentemente.
  3. Virtualiza listas largas (más de ~100 elementos visibles potencialmente).
  4. Aplica code splitting por rutas con lazy y Suspense.
  5. Separa el Context API por dominio si tienes un contexto con mucho contenido no relacionado.
  6. Usa memo en componentes específicos que el Profiler identifique como costosos y que reciben las mismas props frecuentemente.
  7. Usa useMemo/useCallback solo donde el cálculo es genuinamente costoso o donde es necesario para que memo funcione.
  8. Añade debounce a inputs que disparan peticiones de red o cálculos costosos.

¿Te ha gustado esta entrada?

Compártela con tus compañeros para que también sigan aprendiendo.

Comunidad y Comentarios

0 COMENTARIOS

No hay comentarios todavía. Sé el primero en compartir tu opinión.

Escribe tu opinión
Respondiendo a