Conceptos básicos de rendimiento
13 ejemplos para comenzar con React Performance -- 9 básicos y 4 intermedios.
Requisitos previos
Todos los ejemplos asumen un proyecto Next.js 15+ App Router con React 19 y TypeScript. Algunos ejemplos utilizan herramientas adicionales:
- React DevTools (extensión del navegador) para profiling.
@next/bundle-analyzerpara inspección de bundles:npm install --save-dev @next/bundle-analyzer.web-vitalspara monitoreo de Core Web Vitals:npm install web-vitals.zustandpara el ejemplo de rendimiento de estado:npm install zustand.
Dos reglas orientadoras para cada ejemplo a continuación:
- Mide primero. Usa profiling, no adivines -- el Profiler de React DevTools y Lighthouse te dirán dónde está el costo real.
- Los Server Components y el compilador de React hacen gran parte de este trabajo automáticamente. Recurre a
memo,useMemo,useCallbackmanuales solo después de que el profiling demuestre la ganancia.
¿Buscas una revisión sistemática? Consulta la Performance Checklist -- una auditoría de 30 puntos que puedes ejecutar en CI.
Ejemplos básicos
1. Profiling antes de optimizar
Usa React DevTools para registrar una interacción real y ver qué componentes se renderizaron realmente.
import { Profiler, type ProfilerOnRenderCallback } from "react";
const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration) => {
console.log(`[${id}] ${phase} took ${actualDuration.toFixed(1)}ms`);
};
export default function Page() {
return (
<Profiler id="Dashboard" onRender={onRender}>
<Dashboard />
</Profiler>
);
}
function Dashboard() {
return <p>Content</p>;
}- El componente
<Profiler>integrado registra tiempos de renderizado para su subárbol -- excelente para mediciones puntuales. - Para diagnósticos interactivos, abre la pestaña React DevTools Profiler y registra -- muestra un gráfico de llamas de cada commit.
- Ten en cuenta
actualDurationvsbaseDuration-- grandes deltas entre ellos destacan re-renderizados desperdiciados que puedes memoizar. - Nunca envíes un
<Profiler>a producción -- tiene una sobrecarga medible. Envuelve su uso en una verificación de solo desarrollo si permanece en el árbol.
Related: React DevTools Profiler -- gráficos de llamas, inspección de commits, interacciones | Performance Checklist -- qué medir en CI
2. Detener re-renderizados innecesarios
Un re-renderizado del padre no tiene que extenderse en cascada -- divide el estado y mantén referencias estables para que los hijos omitan el trabajo.
"use client";
import { memo, useState } from "react";
const Row = memo(function Row({ label }: { label: string }) {
console.log("Render", label);
return <li>{label}</li>;
});
export default function List({ items }: { items: string[] }) {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
<ul>
{items.map((i) => (
<Row key={i} label={i} />
))}
</ul>
</>
);
}memo(Component)omite re-renderizados cuando los props son referencialmente iguales al último renderizado.- Hacer clic en el contador re-renderiza
List, pero los props deRow(label) no han cambiado, por lo que cada fila se omite. - Los objetos en línea (
style={{ ... }}) y las funciones en línea rompenmemo-- su referencia cambia cada renderizado. - Usa
keyen listas para permitir que React reutilice nodos DOM cuando los elementos se mueven -- unkey={index}en una lista reordenada es una fuente de errores frecuente.
Related: Preventing Unnecessary Re-renders -- división de estado, elevación, claves | Memoization -- las primitivas detrás de este patrón
3. useMemo, useCallback y React.memo
Estabiliza valores costosos y referencias de funciones para que los hijos memoizados realmente se beneficien.
"use client";
import { memo, useCallback, useMemo, useState } from "react";
const ExpensiveChart = memo(function ExpensiveChart({
points,
onPick,
}: {
points: number[];
onPick: (n: number) => void;
}) {
return <p>{points.length} points</p>;
});
export default function Dashboard({ raw }: { raw: number[] }) {
const [picked, setPicked] = useState<number | null>(null);
const points = useMemo(() => raw.filter((n) => n > 0), [raw]);
const onPick = useCallback((n: number) => setPicked(n), []);
return (
<>
<p>Picked: {picked ?? "none"}</p>
<ExpensiveChart points={points} onPick={onPick} />
</>
);
}useMemo(fn, deps)cachea el valor;useCallback(fn, deps)cachea la referencia de la función -- ambos mantienen la identidad de props estable para hijos memoizados.- Sin estos,
raw.filter(...)y(n) => setPicked(n)serían nuevas referencias cada renderizado, invalidandomemo. - No envuelvas cada valor -- la memoización prematura añade código y tiene su propia sobrecarga.
- Una vez que el compilador de React se envía en tu proyecto, generalmente puedes eliminar estas llamadas -- el compilador memoiza automáticamente.
Related: Memoization -- cuándo la memoización realmente ayuda | useMemo / useCallback -- las APIs de hooks
4. Compilador de React (Auto-memoización)
Activa el compilador de React y deja que inserte memoización para ti -- elimina la mayoría de memo/useMemo/useCallback manuales.
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
experimental: {
reactCompiler: true,
},
};
export default config;// Después de que el compilador esté activado, escribe componentes simples:
function ProductList({ products }: { products: Product[] }) {
const total = products.reduce((sum, p) => sum + p.price, 0);
const handleClick = (id: string) => console.log(id);
return <p>{total} ({products.length} items)</p>;
}- El compilador analiza tus componentes en tiempo de compilación e inserta memoización donde es seguro.
- Sin cambios de API -- simplemente opta por esto y escribe React idiomático. El
useMemo/useCallbackexistente sigue funcionando. - Requiere React 19 y una versión compatible de Next/Babel.
npx react-compiler-healthcheckvalida tu codebase. - No reemplaza el profiling -- solo soluciona problemas de identidad de referencias, no los algorítmicos.
Related: React Compiler -- configuración, bailouts, depuración | React Compiler (React 19) -- la página de características de React 19
5. Importación dinámica para división de código
Aplaza la carga de un componente pesado hasta que el usuario realmente lo necesite.
"use client";
import dynamic from "next/dynamic";
import { useState } from "react";
const Chart = dynamic(() => import("./Chart"), {
loading: () => <p>Loading chart...</p>,
ssr: false,
});
export default function Dashboard() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Show chart</button>
{open && <Chart />}
</>
);
}next/dynamicdevuelve un componente que se divide en un chunk separado; se descarga solo cuando se renderiza.- Emparéjalo con un condicional (
open && <Chart />) para que el chunk se obtenga bajo demanda -- ideal para modales, gráficos y editores de texto enriquecido. ssr: falsese excluye del renderizado del servidor cuando el componente depende de APIs solo del navegador (window, document).- Para bibliotecas grandes (gráfico, markdown, WYSIWYG), la importación dinámica por sí sola puede reducir tu bundle inicial por cientos de KB.
Related: Bundle Size Optimization -- analizadores, tree-shaking, costo de paquete | Image & Font Performance -- otras formas de ahorrar bytes
6. Los Server Components envían menos JS
Por defecto, usa Server Components; elimina "use client" solo donde comienza la interactividad.
// app/dashboard/page.tsx -- un Server Component
import { ClientChart } from "./chart";
interface Product {
id: number;
name: string;
price: number;
}
export default async function DashboardPage() {
const res = await fetch("https://api.example.com/products");
const products: Product[] = await res.json();
const total = products.reduce((sum, p) => sum + p.price, 0);
return (
<>
<h1>Total: ${total}</h1>
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
<ClientChart data={products} />
</>
);
}- Los Server Components se renderizan solo en el servidor -- su código nunca se envía al navegador.
- La obtención y computación en el servidor mantiene el bundle del cliente ágil; el
<ClientChart>interactivo es el único JS enviado. - Ahorros típicos: reducción del 30 al 70% en JS del cliente después de mover páginas de lista/detalle a Server Components.
- Solo puedes pasar props serializables a través del límite servidor/cliente -- sin funciones, sin instancias de clase.
Related: Server Component Performance -- límites, patrones, mediciones | Server Components (React 19) -- la primitiva
7. next/image para LCP rápido
Usa <Image> de Next.js para que el navegador envíe el tamaño correcto, cargue perezosamente por debajo del pliegue y reserve espacio para evitar cambios de diseño.
import Image from "next/image";
export default function Hero() {
return (
<Image
src="/hero.jpg"
alt="Landing hero"
width={1200}
height={630}
priority
sizes="(max-width: 768px) 100vw, 1200px"
/>
);
}next/imagesirve formatos modernos (AVIF/WebP), genera múltiples tamaños y carga perezosamente automáticamente.- El
widthyheightreservan espacio -- cero cambio de diseño incluso antes de que llegue la imagen. prioritydesactiva la carga perezosa para imágenes por encima del pliegue para que comiencen a cargarse inmediatamente (gran ganancia de LCP).sizesle dice al navegador qué tan ancha será la imagen en cada punto de ruptura -- necesario para elegir la fuente correcta.
Related: Image & Font Performance -- carga de fuentes, precarga, imágenes OG | next/image -- API completa de
<Image>
8. Medir Core Web Vitals
Envía métricas LCP, INP y CLS de usuarios reales a analytics para que sepas qué ve el mundo real.
// app/web-vitals.tsx
"use client";
import { useReportWebVitals } from "next/web-vitals";
export function WebVitals() {
useReportWebVitals((metric) => {
console.log(metric.name, metric.value);
// fetch("/api/metrics", { method: "POST", body: JSON.stringify(metric) });
});
return null;
}
// app/layout.tsx
// <WebVitals />- LCP < 2.5s (pintura de contenido más grande), INP < 200ms (latencia de interacción, reemplazó FID en 2024), CLS < 0.1 (cambio de diseño).
useReportWebVitalsse dispara una vez por métrica por página -- reenvíalas a tu analytics, Sentry o Vercel Analytics.- Las puntuaciones de laboratorio (Lighthouse) son un techo; las métricas de campo de usuarios reales son lo que Google clasifica y lo que debes observar.
- Establece presupuestos en CI (Lighthouse CI, Speed Insights) para que las regresiones fallen la construcción, no tus usuarios.
Related: Core Web Vitals Optimization -- correcciones por métrica y medición | Performance Checklist -- puertas CI y presupuestos
9. Suspense para carga percibida más rápida
Divide una ruta en límites de transmisión para que los paneles rápidos se rendericen mientras los lentos aún se están obteniendo.
// app/dashboard/page.tsx
import { Suspense } from "react";
import { FastStats } from "./fast-stats";
import { SlowChart } from "./slow-chart";
export default function Dashboard() {
return (
<div>
<Suspense fallback={<p>Loading stats...</p>}>
<FastStats />
</Suspense>
<Suspense fallback={<p>Loading chart...</p>}>
<SlowChart />
</Suspense>
</div>
);
}- Cada
<Suspense>se transmite independientemente -- el usuario ve el panel rápido inmediatamente en lugar de esperar a que se cargue toda la ruta. - Los fallbacks de esqueleto deben coincidir con el diseño final para que la llegada de contenido no desplace la página.
- Usa
loading.tsxpara transmisión a nivel de ruta, y<Suspense>anidado para transmisión granular por panel. - Empareja con obtenciones paralelas dentro de cada límite (
Promise.all) para que cada panel se cargue completamente en el momento en que su JS se transmita.
Related: Suspense & Streaming Performance -- estrategia de límites | Suspense (patterns) -- la primitiva | Streaming (Next.js Data) -- transmisión a nivel de ruta
Ejemplos intermedios
10. Selector de Zustand para alcance de re-renderizados
Suscríbete a un slice de la tienda para que solo los componentes que leen ese slice se re-rendericen.
"use client";
import { create } from "zustand";
interface CartStore {
items: { id: string; qty: number }[];
count: number;
addItem: (id: string) => void;
}
const useCart = create<CartStore>((set) => ({
items: [],
count: 0,
addItem: (id) =>
set((s) => {
const items = [...s.items, { id, qty: 1 }];
return { items, count: items.length };
}),
}));
// Solo se re-renderiza cuando `count` cambia, no cuando cambia cualquier otro campo
function CartBadge() {
const count = useCart((s) => s.count);
return <span>{count}</span>;
}
// Solo se re-renderiza cuando la referencia de `addItem` cambia (nunca, a menos que la tienda se recree)
function AddButton({ id }: { id: string }) {
const addItem = useCart((s) => s.addItem);
return <button onClick={() => addItem(id)}>Add</button>;
}- Pasar una función selectora al hook le dice a Zustand que compare solo ese slice entre renderizados.
- Los componentes que solo leen acciones (sin estado) efectivamente nunca se re-renderizan desde la tienda -- las acciones tienen identidad estable.
- Devolver un nuevo objeto de un selector en cada llamada derrotar la optimización -- usa igualdad
shallowo devuelve primitivos. - Context no tiene un modelo selector integrado; recurre a Zustand/Jotai cuando los re-renderizados de contexto se conviertan en el cuello de botella.
Related: State Management Performance -- división de contexto, estado derivado | Zustand Selectors -- patrones selectores más profundos
11. Obtención de datos paralela para eliminar cascadas
Inicia obtenciones independientes en paralelo para que el tiempo total de espera sea el más lento, no la suma.
// app/dashboard/page.tsx
interface User { name: string; }
interface Stats { totalSales: number; }
interface Activity { events: string[]; }
async function getUser(): Promise<User> {
return (await fetch("https://api.example.com/me")).json();
}
async function getStats(): Promise<Stats> {
return (await fetch("https://api.example.com/stats")).json();
}
async function getActivity(): Promise<Activity> {
return (await fetch("https://api.example.com/activity")).json();
}
export default async function Dashboard() {
const [user, stats, activity] = await Promise.all([
getUser(),
getStats(),
getActivity(),
]);
return (
<p>
{user.name} — ${stats.totalSales} — {activity.events.length} events
</p>
);
}- Los
awaitsecuenciales crean una cascada -- cada solicitud espera a que se resuelva la anterior. Promise.alldispara todas las solicitudes a la vez; el tiempo total es igual a la solicitud más lenta.- Usa
Promise.allSettledcuando un fallo no debe rechazar todo el conjunto (por ejemplo, datos de barra lateral opcionales). - Para trabajo paralelo en el árbol, prefiere límites Suspense por panel para que los datos rápidos se transmitan primero.
Related: Data Fetching Performance -- cascadas, paralelismo, caché | Parallel Promises -- patrones Promise.all, allSettled
12. Limpieza para evitar pérdidas de memoria
Siempre desmonta suscripciones, temporizadores y oyentes en el useEffect cleanup.
"use client";
import { useEffect, useState } from "react";
export default function LiveCounter() {
const [n, setN] = useState(0);
useEffect(() => {
const controller = new AbortController();
const id = setInterval(() => setN((x) => x + 1), 1000);
window.addEventListener("resize", () => console.log("resize"), {
signal: controller.signal,
});
return () => {
clearInterval(id);
controller.abort(); // elimina el oyente
};
}, []);
return <p>Ticks: {n}</p>;
}- Cada
setInterval,setTimeout,addEventListenery suscripción debe tener una limpieza correspondiente, o el navegador mantiene el componente para siempre. AbortControlleres la forma moderna de eliminar oyentes -- unabort()desmonta cada oyente registrado con la señal.- Abortar llamadas
fetchen vuelo con la misma señal evita advertencias "setState después del desmontaje". - Usa la pestaña Memory de Chrome DevTools para tomar instantáneas de montón antes y después de la navegación -- los nodos DOM desconectados son la pista.
Related: Memory Leaks -- detección, patrones WeakMap, refs de larga vida | useEffect -- reglas de limpieza y temporización
13. Caché de Next.js + Revalidación basada en etiquetas
Cachea obtenciones costosas entre solicitudes e invalídalas quirúrgicamente cuando los datos cambian.
// app/lib/data.ts — servidor-solo helper
import { unstable_cache } from "next/cache";
interface Post {
id: number;
title: string;
}
export const getPosts = unstable_cache(
async (): Promise<Post[]> => {
const res = await fetch("https://api.example.com/posts");
return res.json();
},
["posts"],
{ tags: ["posts"], revalidate: 300 },
);
// app/posts/actions.ts
"use server";
import { revalidateTag } from "next/cache";
export async function createPost(title: string) {
await fetch("https://api.example.com/posts", {
method: "POST",
body: JSON.stringify({ title }),
});
revalidateTag("posts");
}unstable_cache(fn, keys, options)memoiza el resultado entre solicitudes -- los llamadores posteriores obtienen un acierto de caché.tags: ["posts"]te permite invalidar cada entrada en caché con esa etiqueta en una llamada a través derevalidateTag("posts").revalidate: 300añade un techo de tiempo de 5 minutos en caso de que olvides etiqueta-invalidar en algún lugar.- Para memoización de alcance de solicitud (dedup dentro de un árbol de renderizado), el
fetchsimple ya memoiza -- reservaunstable_cachepara compartir entre solicitudes.
Related: Next.js Caching Deep Dive -- modelo de cuatro capas, ciclos de vida del caché | Caching (Next.js Data) -- opciones de caché a nivel de obtención | Revalidation -- revalidatePath vs revalidateTag vs ISR