Skip to content

Rendimiento ​

React es rápido por defecto, pero en apps grandes pueden aparecer lentitudes: listas que tardan en filtrar, escritura con retraso, páginas que cargan mucho JavaScript. Aprenderás a medir antes de optimizar y las técnicas que realmente funcionan: evitar renders innecesarios, memo, división de código con lazy, transiciones, virtualización y el React Compiler.

Concepto ​

¿Cuándo se vuelve a renderizar un componente? ​

  1. Cuando cambia su estado.
  2. Cuando su padre se vuelve a renderizar (aunque sus props no cambien).
  3. Cuando cambia un contexto que consume.

Renderizar no es malo: React compara y sólo toca el DOM si algo cambió. El problema aparece cuando un render es costoso o se repite miles de veces.

Regla de oro: mide primero, optimiza después. La optimización prematura complica el código sin beneficio.

Medir ​

React DevTools Profiler ​

  1. Instala React Developer Tools.
  2. Abre la pestaña Profiler → ⏺ Grabar → interactúa con la app → ⏹ Detener.
  3. Verás qué componentes se renderizaron, cuántas veces y cuánto tardaron. En ⚙️ activa "Highlight updates when components render" para ver destellos en cada render.

Lighthouse y Web Vitals ​

En Chrome DevTools → Lighthouse obtienes métricas como LCP (cuánto tarda en verse el contenido principal), INP (qué tan rápido responde a interacciones) y CLS (saltos de diseño). Mide siempre sobre el build de producción (npm run build && npm run preview): el modo desarrollo es mucho más lento.

Técnica 1: mover el estado hacia abajo ​

Antes de memorizar nada, revisa dónde vive el estado. Si un estado sólo afecta a una parte, colócalo en esa parte.

jsx
// ❌ Escribir en el input re-renderiza TODA la página (incluida la lista pesada)
function Pagina() {
  const [busqueda, setBusqueda] = useState('')
  return (
    <>
      <input value={busqueda} onChange={(e) => setBusqueda(e.target.value)} />
      <ListaPesada />
    </>
  )
}

// ✅ El estado vive en su propio componente: ListaPesada no se re-renderiza
function Buscador() {
  const [busqueda, setBusqueda] = useState('')
  return <input value={busqueda} onChange={(e) => setBusqueda(e.target.value)} />
}

function Pagina() {
  return (
    <>
      <Buscador />
      <ListaPesada />
    </>
  )
}

Otra variante: pasar componentes como children. Los children creados por el padre no se re-renderizan cuando cambia el estado del componente que los envuelve.

Técnica 2: memo ​

memo envuelve un componente para que no se vuelva a renderizar si sus props son iguales (comparación superficial).

jsx
import { memo } from 'react'

const TarjetaProducto = memo(function TarjetaProducto({ producto, onAgregar }) {
  return (
    <div>
      <h3>{producto.titulo}</h3>
      <button onClick={() => onAgregar(producto)}>Agregar</button>
    </div>
  )
})

Para que funcione, las props deben mantener la misma referencia: objetos con useMemo y funciones con useCallback (lección 12). Si pasas onAgregar={() => ...} creado en cada render, memo no sirve de nada.

Técnica 3: React Compiler ​

El React Compiler analiza tus componentes al compilar y agrega automáticamente la memorización necesaria. Con él, la mayoría de los memo, useMemo y useCallback manuales sobran.

bash
npm i -D babel-plugin-react-compiler
js
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: ['babel-plugin-react-compiler']
      }
    })
  ]
})

Requisito: que tu código siga las reglas de React (componentes puros, no mutar props ni estado). El plugin de ESLint eslint-plugin-react-hooks en su versión reciente incluye reglas que detectan código incompatible.

Técnica 4: división de código con lazy ​

Por defecto, Vite empaqueta toda la app en un archivo. Si tienes un panel de administración pesado que sólo ven algunos usuarios, ¿por qué descargarlo para todos? Con lazy cada página se descarga cuando se visita.

jsx
// src/router.jsx
import { lazy, Suspense } from 'react'
import { createBrowserRouter } from 'react-router'
import Layout from './layouts/Layout'
import Inicio from './pages/Inicio' // la página principal se carga normal

// Se descargan sólo al navegar a ellas
const Productos = lazy(() => import('./pages/Productos'))
const Admin = lazy(() => import('./pages/Admin'))

const Cargando = () => <p style={{ padding: 24 }}>Cargando…</p>

export const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      { index: true, element: <Inicio /> },
      {
        path: 'productos',
        element: (
          <Suspense fallback={<Cargando />}>
            <Productos />
          </Suspense>
        )
      },
      {
        path: 'admin',
        element: (
          <Suspense fallback={<Cargando />}>
            <Admin />
          </Suspense>
        )
      }
    ]
  }
])

También sirve para componentes pesados que no siempre se muestran (un editor de texto enriquecido, un mapa, una librería de gráficas):

jsx
const EditorRico = lazy(() => import('./components/EditorRico'))

function FormularioPost() {
  const [mostrarEditor, setMostrarEditor] = useState(false)
  return (
    <>
      <button onClick={() => setMostrarEditor(true)}>Escribir</button>
      {mostrarEditor && (
        <Suspense fallback={<p>Cargando editor…</p>}>
          <EditorRico />
        </Suspense>
      )}
    </>
  )
}

Revisa el tamaño de tu build: npm run build muestra cada archivo con su peso. Para un reporte visual, usa rollup-plugin-visualizer.

Técnica 5: useTransition y useDeferredValue ​

Algunas actualizaciones son urgentes (escribir en un input) y otras pueden esperar (filtrar una lista enorme). React 18+ permite marcar las segundas como de baja prioridad para que la interfaz no se trabe.

jsx
import { useDeferredValue, useMemo, useState } from 'react'

const ITEMS = Array.from({ length: 20000 }, (_, i) => `Elemento número ${i + 1}`)

export default function FiltroGrande() {
  const [texto, setTexto] = useState('')
  // Versión "atrasada" de texto: React la actualiza cuando tiene tiempo libre
  const textoDiferido = useDeferredValue(texto)
  const desactualizado = texto !== textoDiferido

  const filtrados = useMemo(
    () => ITEMS.filter((it) => it.toLowerCase().includes(textoDiferido.toLowerCase())),
    [textoDiferido]
  )

  return (
    <div>
      {/* El input responde al instante */}
      <input value={texto} onChange={(e) => setTexto(e.target.value)} placeholder="Filtrar 20,000 elementos" />
      <p>{filtrados.length} resultados</p>
      <ul style={{ opacity: desactualizado ? 0.5 : 1 }}>
        {filtrados.slice(0, 200).map((it) => <li key={it}>{it}</li>)}
      </ul>
    </div>
  )
}

useTransition hace lo mismo pero envolviendo la actualización de estado:

jsx
const [pendiente, startTransition] = useTransition()

function cambiarPestana(nueva) {
  startTransition(() => setPestana(nueva)) // no bloquea la interfaz
}
// {pendiente && <Spinner />}

Técnica 6: virtualizar listas largas ​

Dibujar 10,000 filas en el DOM es lento, aunque sólo veas 20. La virtualización dibuja únicamente las filas visibles y las recicla al hacer scroll.

bash
npm i @tanstack/react-virtual
jsx
import { useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'

const FILAS = Array.from({ length: 10000 }, (_, i) => ({ id: i, nombre: `Cliente ${i + 1}` }))

export default function ListaVirtual() {
  const contenedorRef = useRef(null)

  const virtualizador = useVirtualizer({
    count: FILAS.length,
    getScrollElement: () => contenedorRef.current,
    estimateSize: () => 40, // alto aproximado de cada fila en px
    overscan: 5             // filas extra arriba y abajo para un scroll suave
  })

  return (
    <div ref={contenedorRef} style={{ height: 400, overflowY: 'auto', border: '1px solid #ddd' }}>
      {/* Contenedor con el alto total para que la barra de scroll sea correcta */}
      <div style={{ height: virtualizador.getTotalSize(), position: 'relative' }}>
        {virtualizador.getVirtualItems().map((fila) => (
          <div
            key={fila.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: fila.size,
              transform: `translateY(${fila.start}px)`,
              padding: '8px 12px',
              borderBottom: '1px solid #f0f0f0'
            }}
          >
            {FILAS[fila.index].nombre}
          </div>
        ))}
      </div>
    </div>
  )
}

Alternativa más simple: paginación o scroll infinito (cargar 20 más al llegar al final).

Técnica 7: imágenes y recursos ​

jsx
<img
  src="/img/producto-800.webp"
  srcSet="/img/producto-400.webp 400w, /img/producto-800.webp 800w"
  sizes="(max-width: 640px) 400px, 800px"
  width={800}
  height={800}            // evita saltos de diseño (CLS)
  loading="lazy"          // carga diferida si está fuera de pantalla
  decoding="async"
  alt="Café de Chiapas"
/>
  • Usa formatos modernos (WebP, AVIF) y tamaños adecuados.
  • La imagen principal de la página (hero) sin loading="lazy" y con fetchPriority="high".
  • Fuentes: pocas variantes y font-display: swap.

Checklist ​

ProblemaSolución
Escribir en un input se siente lentoMover estado hacia abajo, useDeferredValue
Un componente se renderiza de másMover estado, memo + useCallback, React Compiler
Lista de miles de elementosVirtualización o paginación
La app tarda en cargar la primera vezlazy por rutas, revisar dependencias pesadas
Cálculo costoso en cada renderuseMemo
Contexto que cambia seguido re-renderiza todoDividir contextos o usar Zustand con selectores
Imágenes pesadasWebP/AVIF, srcSet, loading="lazy", dimensiones

Buena práctica

Revisa el costo de cada dependencia en bundlephobia.com antes de instalarla. Por ejemplo, moment pesa mucho más que dayjs o las APIs nativas Intl.

Error común

Medir en modo desarrollo. StrictMode duplica renders y el código no está minificado. Toma decisiones de rendimiento con npm run build + npm run preview.

Ejercicios ​

  1. En una app con un Header que muestra un reloj (se actualiza cada segundo) y una TablaGrande, evita que la tabla se re-renderice cada segundo sin usar memo.
  2. Convierte las páginas Productos, Carrito y Admin de tu router a carga diferida con lazy y un componente Suspense compartido en el Layout.
Ver solución
jsx
// 1. Mover el estado del reloj a su propio componente
import { useEffect, useState } from 'react'

function Reloj() {
  const [hora, setHora] = useState(() => new Date())
  useEffect(() => {
    const id = setInterval(() => setHora(new Date()), 1000)
    return () => clearInterval(id)
  }, [])
  return <span>{hora.toLocaleTimeString('es-MX')}</span>
}

function Header() {
  return <header>Mi panel · <Reloj /></header> // Header ya no tiene estado
}

export default function App() {
  return (
    <>
      <Header />
      <TablaGrande /> {/* no se re-renderiza con cada tic */}
    </>
  )
}
jsx
// 2. src/layouts/Layout.jsx
import { Suspense } from 'react'
import { Outlet } from 'react-router'

export default function Layout() {
  return (
    <>
      <header>…</header>
      <main>
        <Suspense fallback={<p>Cargando página…</p>}>
          <Outlet />
        </Suspense>
      </main>
    </>
  )
}

// src/router.jsx
import { lazy } from 'react'
const Productos = lazy(() => import('./pages/Productos'))
const Carrito = lazy(() => import('./pages/Carrito'))
const Admin = lazy(() => import('./pages/Admin'))

// children: [
//   { index: true, element: <Inicio /> },
//   { path: 'productos', element: <Productos /> },
//   { path: 'carrito', element: <Carrito /> },
//   { path: 'admin', element: <Admin /> }
// ]

Resumen ​

  • Mide con el Profiler y Lighthouse sobre el build de producción antes de optimizar.
  • Primero mueve el estado hacia abajo; luego memo + useCallback, o deja que el React Compiler lo haga.
  • lazy + Suspense dividen el código por rutas o componentes pesados.
  • useDeferredValue/useTransition mantienen la interfaz fluida; virtualiza listas enormes.

Academia Web — aprende, copia y construye.