Skip to main content
frontend21 de diciembre de 20258 min de lectura

5 Errores de Rendimiento que Matan Tu App de React

La mayoría de las apps de React son más lentas de lo necesario. Aquí están los 5 errores de rendimiento más comunes que veo en codebases de producción — y cómo corregir cada uno.

reactperformancenextjs
5 Errores de Rendimiento que Matan Tu App de React

He revisado cientos de codebases de React a lo largo de los años. Proyectos de clientes, repositorios open source, herramientas internas, MVPs de startups. Los mismos problemas de rendimiento aparecen una y otra vez. No son casos extremos exóticos — son errores básicos y prevenibles que se acumulan hasta que la app se siente lenta y los usuarios empiezan a irse.

Aquí están los cinco que veo más frecuentemente, con soluciones reales que puedes aplicar hoy.

1. Re-renders Innecesarios por Falta de Memoización

Este es el problema de rendimiento más común en apps de React. Un componente padre se re-renderiza, y cada hijo se re-renderiza con él — incluso si sus props no han cambiado. Multiplica esto a través de un árbol de componentes profundo con docenas de elementos en una lista, y tienes una app que tartamudea con cada pulsación de tecla.

El problema:

function Dashboard({ user }) {
  const [searchQuery, setSearchQuery] = useState("");

  const stats = calculateStats(user.transactions);

  return (
    <div>
      <SearchBar value={searchQuery} onChange={setSearchQuery} />
      <StatsPanel stats={stats} />
      <TransactionList transactions={user.transactions} />
    </div>
  );
}

Cada vez que searchQuery cambia, calculateStats se ejecuta de nuevo — aunque user.transactions no haya cambiado. StatsPanel y TransactionList también se re-renderizan sin razón.

La solución:

function Dashboard({ user }) {
  const [searchQuery, setSearchQuery] = useState("");

  const stats = useMemo(
    () => calculateStats(user.transactions),
    [user.transactions]
  );

  const handleSearchChange = useCallback(
    (value: string) => setSearchQuery(value),
    []
  );

  return (
    <div>
      <SearchBar value={searchQuery} onChange={handleSearchChange} />
      <MemoizedStatsPanel stats={stats} />
      <MemoizedTransactionList transactions={user.transactions} />
    </div>
  );
}

const MemoizedStatsPanel = memo(StatsPanel);
const MemoizedTransactionList = memo(TransactionList);

Tres cosas pasaron aquí:

  1. useMemo previene que calculateStats se ejecute en cada render. Solo recalcula cuando user.transactions realmente cambia.
  2. useCallback estabiliza la referencia al manejador de cambio para que SearchBar no vea una función "nueva" en cada render.
  3. memo envuelve los componentes hijos para que se salten el re-render cuando sus props son superficialmente iguales.

Una advertencia: no envuelvas cada componente en memo a ciegas. Perfila primero usando React DevTools Profiler. La memoización tiene un costo — la comparación superficial en sí. Aplícala donde importa: cálculos costosos, listas grandes y componentes que se re-renderizan frecuentemente con props sin cambios.

2. Bundles Inflados por Cero Code Splitting

Regularmente veo apps de React que envían todo en un solo bundle de JavaScript. El dashboard de administración, la página de configuración, el centro de ayuda raramente visitado — todo cargado de entrada en la primera visita a la página. El usuario quería ver una landing page, y su navegador acaba de descargar 2 MB de JavaScript que no necesita todavía.

El problema:

import AdminDashboard from "./pages/AdminDashboard";
import Settings from "./pages/Settings";
import HelpCenter from "./pages/HelpCenter";
import Landing from "./pages/Landing";

function App() {
  return (
    <Routes>
      <Route path="/" element={<Landing />} />
      <Route path="/admin" element={<AdminDashboard />} />
      <Route path="/settings" element={<Settings />} />
      <Route path="/help" element={<HelpCenter />} />
    </Routes>
  );
}

Los imports estáticos significan que el bundler incluye todo en un chunk. Sin importar qué ruta visite el usuario, descarga todo.

La solución:

import { lazy, Suspense } from "react";

const AdminDashboard = lazy(() => import("./pages/AdminDashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const HelpCenter = lazy(() => import("./pages/HelpCenter"));
import Landing from "./pages/Landing"; // Keep critical path static

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/" element={<Landing />} />
        <Route path="/admin" element={<AdminDashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/help" element={<HelpCenter />} />
      </Routes>
    </Suspense>
  );
}

React.lazy con import() dinámico le dice al bundler que separe estas páginas en chunks separados. Se cargan bajo demanda cuando el usuario navega a esa ruta. Tu bundle inicial se mantiene pequeño, y tu Time to Interactive baja significativamente.

Si estás usando Next.js, obtienes code splitting basado en rutas gratis con el App Router. Pero aún necesitas ser intencional sobre el code splitting a nivel de componente para librerías de terceros pesadas:

import dynamic from "next/dynamic";

const RichTextEditor = dynamic(() => import("@/components/RichTextEditor"), {
  loading: () => <EditorSkeleton />,
  ssr: false,
});

Esto es crítico para librerías como editores de texto enriquecido, librerías de gráficos o renderizadores de PDF que pueden agregar fácilmente 200-500 KB a tu bundle.

3. Estado Ubicado Demasiado Alto en el Árbol de Componentes

El modelo de renderizado de React es de arriba hacia abajo. Cuando el estado de un componente cambia, se re-renderiza — y también todo su subárbol. Cuando elevas el estado a la cima del árbol "por conveniencia," le estás diciendo a React que re-renderice todo debajo en cada cambio de estado.

El problema:

function App() {
  const [selectedFilter, setSelectedFilter] = useState("all");
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [searchQuery, setSearchQuery] = useState("");

  return (
    <Layout>
      <Header />
      <Sidebar filter={selectedFilter} onFilterChange={setSelectedFilter} />
      <MainContent>
        <SearchBar query={searchQuery} onChange={setSearchQuery} />
        <ProductList filter={selectedFilter} />
        <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
      </MainContent>
      <Footer />
    </Layout>
  );
}

Escribir en la barra de búsqueda dispara una actualización de estado en App. Esto re-renderiza Header, Sidebar, Footer, ProductList y Modal — ninguno de los cuales se preocupa por el query de búsqueda.

La solución:

Empuja el estado hacia donde realmente se usa:

function App() {
  return (
    <Layout>
      <Header />
      <SidebarWithFilter />
      <MainContent>
        <SearchSection />
        <ProductList />
      </MainContent>
      <Footer />
    </Layout>
  );
}

function SearchSection() {
  const [searchQuery, setSearchQuery] = useState("");

  return (
    <div>
      <SearchBar query={searchQuery} onChange={setSearchQuery} />
      <SearchResults query={searchQuery} />
    </div>
  );
}

Ahora escribir en la barra de búsqueda solo re-renderiza SearchSection y sus hijos. Todo lo demás permanece intocado.

El principio es simple: el estado debe vivir lo más cerca posible de los componentes que lo leen. Si dos componentes hermanos necesitan el mismo estado, elévalo a su padre común más cercano — pero no más alto. Para estado verdaderamente global (auth, tema, locale), usa una librería de gestión de estado que soporte suscripciones selectivas, como Zustand o Jotai, para que solo los componentes que consumen un slice específico de estado se re-rendericen cuando ese slice cambia.

4. Imágenes Sin Optimizar en Next.js

Las imágenes son típicamente los assets más pesados en una página web. He visto apps de Next.js donde los desarrolladores usan etiquetas <img> simples con PNGs masivos y sin comprimir. El resultado: una página que carga 8 MB de imágenes, cero lazy loading, sin dimensionamiento responsive y un Largest Contentful Paint que hace llorar a Lighthouse.

El problema:

function ProjectCard({ project }) {
  return (
    <div className="card">
      <img src={`/images/${project.cover}`} alt={project.title} />
      <h3>{project.title}</h3>
      <p>{project.description}</p>
    </div>
  );
}

Esto sirve la imagen a resolución completa en su tamaño original, independientemente del viewport o dispositivo del usuario. Sin conversión de formato, sin lazy loading, sin srcset responsive.

La solución:

import Image from "next/image";

function ProjectCard({ project }) {
  return (
    <div className="card">
      <Image
        src={`/images/${project.cover}`}
        alt={project.title}
        width={600}
        height={400}
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
        placeholder="blur"
        blurDataURL={project.blurHash}
      />
      <h3>{project.title}</h3>
      <p>{project.description}</p>
    </div>
  );
}

El componente Image de Next.js te da varias cosas automáticamente:

  • Conversión automática de formato a WebP o AVIF según el soporte del navegador
  • Dimensionamiento responsive vía el atributo sizes — el navegador descarga solo el tamaño que necesita
  • Lazy loading por defecto — las imágenes debajo del fold no se descargan hasta que el usuario se acerca haciendo scroll
  • Placeholder blur — muestra una vista previa ligera mientras la imagen completa carga, eliminando el layout shift

Para imágenes arriba del fold (banners hero, contenido principal), agrega priority para deshabilitar el lazy loading y precargarlas:

<Image src={heroImage} alt="Hero" priority sizes="100vw" />

Este solo cambio puede reducir el peso de la página en un 60-80% y mejorar dramáticamente tus puntuaciones de Core Web Vitals.

5. Bloqueando el Hilo Principal con Cálculos Pesados

El hilo principal del navegador maneja el renderizado, la entrada del usuario, las animaciones y la ejecución de JavaScript — todo en un solo hilo. Cuando ejecutas un cálculo pesado de forma síncrona, todo lo demás se congela. El usuario hace clic en un botón y nada pasa por 500ms. Las animaciones se entrecortan. El scroll deja de responder.

El problema:

function AnalyticsDashboard({ rawData }) {
  const [processedData, setProcessedData] = useState(null);

  useEffect(() => {
    // This blocks the main thread for 200-800ms
    const result = processLargeDataset(rawData); // Sorting, filtering, aggregating 50k+ rows
    setProcessedData(result);
  }, [rawData]);

  if (!processedData) return <Spinner />;
  return <Charts data={processedData} />;
}

processLargeDataset se ejecuta síncronamente en el hilo principal. Si toma 400ms, toda la UI está congelada por 400ms. El usuario no puede hacer scroll, escribir ni interactuar con nada.

La solución — moverlo a un Web Worker:

// workers/dataProcessor.ts
self.onmessage = (event: MessageEvent) => {
  const rawData = event.data;
  const result = processLargeDataset(rawData);
  self.postMessage(result);
};
function AnalyticsDashboard({ rawData }) {
  const [processedData, setProcessedData] = useState(null);
  const workerRef = useRef<Worker | null>(null);

  useEffect(() => {
    workerRef.current = new Worker(
      new URL("../workers/dataProcessor.ts", import.meta.url)
    );

    workerRef.current.onmessage = (event: MessageEvent) => {
      setProcessedData(event.data);
    };

    workerRef.current.postMessage(rawData);

    return () => {
      workerRef.current?.terminate();
    };
  }, [rawData]);

  if (!processedData) return <Spinner />;
  return <Charts data={processedData} />;
}

El cálculo pesado ahora se ejecuta en un hilo separado. El hilo principal queda libre para manejar interacciones del usuario, animaciones y renderizado. La UI permanece responsive todo el tiempo.

Para casos menos extremos donde el cálculo toma 50-150ms, puedes dividir el trabajo en chunks más pequeños usando requestIdleCallback o scheduler.yield() en lugar de crear un Worker completo. Pero para cualquier cosa que procese conjuntos de datos grandes, genere reportes o ejecute algoritmos complejos, los Web Workers no son opcionales — son necesarios.

El Efecto Compuesto

Ninguno de estos errores va a crashear tu app por sí solo. Eso es lo que los hace peligrosos. Cada uno agrega 100-300ms de retraso, y los usuarios experimentan la suma total. Un componente que se re-renderiza innecesariamente más un bundle inflado más imágenes sin optimizar más un cálculo en el hilo principal significa que tu app toma 4 segundos en volverse interactiva en lugar de 1.

Perfila antes de optimizar. Usa React DevTools Profiler para encontrar puntos calientes de re-render. Usa Lighthouse y Web Vitals para medir el impacto real en usuarios. Usa la pestaña Network de tu navegador para detectar bundles e imágenes sobredimensionados. Usa la pestaña Performance para identificar tareas largas bloqueando el hilo principal.

Estos son patrones que aplico en cada proyecto que construyo — desde plataformas de restaurantes hasta sistemas SaaS médicos. El rendimiento no es una funcionalidad que agregas después. Es una cualidad que mantienes desde el primer commit.

DU

Danil Ulmashev

Full Stack Developer

Interesado en trabajar juntos?