Límites de Suspense — Maneja declarativamente estados de carga para componentes y datos asincrónos
Receta
import { Suspense } from "react";
// Envuelve componentes asincronos en límites de Suspense
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Dashboard />
</Suspense>
);
}
// React 19: hook use() para datos basados en promesas
import { use } from "react";
function Dashboard({ dataPromise }: { dataPromise: Promise<DashboardData> }) {
const data = use(dataPromise);
return <DashboardView data={data} />;
}Cuándo usarlo: Cuando los componentes necesitan esperar datos asincronos, código cargado dinámicamente o contenido transmitido por el servidor. Suspense reemplaza el estado manual isLoading con límites de carga declarativos.
Ejemplo Funcional
import { Suspense, use, useState, useTransition, lazy, type ReactNode } from "react";
// --- Obtención de datos con use() y Suspense ---
interface Post {
id: number;
title: string;
body: string;
}
// Caché para promesas de fetch (ejemplo simple — usa una biblioteca en producción)
const cache = new Map<string, Promise<Post[]>>();
function fetchPosts(userId: number): Promise<Post[]> {
const key = `posts-${userId}`;
if (!cache.has(key)) {
cache.set(
key,
fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`)
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch posts");
return res.json();
})
);
}
return cache.get(key)!;
}
function PostList({ postsPromise }: { postsPromise: Promise<Post[]> }) {
const posts = use(postsPromise);
return (
<ul className="space-y-4">
{posts.map((post) => (
<li key={post.id} className="border rounded-lg p-4">
<h3 className="font-semibold">{post.title}</h3>
<p className="text-gray-600 mt-1">{post.body}</p>
</li>
))}
</ul>
);
}
// Cargador esqueleto
function PostListSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="border rounded-lg p-4 animate-pulse">
<div className="h-5 bg-gray-200 rounded w-3/4 mb-2" />
<div className="h-4 bg-gray-200 rounded w-full" />
<div className="h-4 bg-gray-200 rounded w-5/6 mt-1" />
</div>
))}
</div>
);
}
// --- Página con múltiples límites de Suspense ---
function UserDashboard() {
const [userId, setUserId] = useState(1);
const [isPending, startTransition] = useTransition();
const handleUserChange = (id: number) => {
startTransition(() => {
setUserId(id);
});
};
return (
<div className="max-w-2xl mx-auto p-6">
<nav className="flex gap-2 mb-6">
{[1, 2, 3].map((id) => (
<button
key={id}
onClick={() => handleUserChange(id)}
className={`px-4 py-2 rounded ${
userId === id ? "bg-blue-600 text-white" : "bg-gray-100"
} ${isPending ? "opacity-50" : ""}`}
>
User {id}
</button>
))}
</nav>
<ErrorBoundary fallback={<p className="text-red-600">Failed to load posts.</p>}>
<Suspense fallback={<PostListSkeleton />}>
<PostList postsPromise={fetchPosts(userId)} />
</Suspense>
</ErrorBoundary>
</div>
);
}
// --- Carga dinámica con Suspense ---
const Settings = lazy(() => import("./Settings"));
const Analytics = lazy(() => import("./Analytics"));
function AppRoutes() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
);
}Lo que esto demuestra:
- Hook
use()de React 19 consumiendo una promesa, disparando Suspense automáticamente useTransitionpara mantener la interfaz actual visible mientras se cargan nuevos datos (evitando un destello del estado de carga)ErrorBoundary+Suspenseemparejados para un manejo completo del estado asincrónico- Estado
isPendingpara mostrar un indicador de carga no bloqueante - División de código perezosa con
React.lazyy Suspense
Análisis Profundo
Cómo Funciona
- Cuando un componente dentro de un límite de Suspense se suspende (lanza una promesa), React muestra el
fallbacken lugar del subárbol suspendido. - Una vez que la promesa se resuelve, React vuelve a intentar renderizar el componente con los datos resueltos.
use()(React 19) lee el valor de una promesa. Si la promesa aún no se ha resuelto, suspende el componente.React.lazy()envuelve una importación dinámica y se suspende hasta que el módulo se carga.useTransitionenvuelve actualizaciones de estado para que Suspense muestre la interfaz anterior con un indicador pendiente en lugar del fallback.- Los componentes de servidor de Next.js usan límites de Suspense para transmisión — el servidor envía el fallback primero, luego transmite el contenido resuelto.
Parámetros y Valores de Retorno
| API | Parámetros | Propósito |
|---|---|---|
<Suspense> | fallback: ReactNode | Muestra fallback mientras los componentes secundarios se suspenden |
use(promise) | Promise<T> | Lee el valor de la promesa, se suspende si está pendiente |
use(context) | Context<T> | Lee contexto (puede ser llamado condicionalmente en React 19) |
React.lazy(loader) | () => Promise<{ default: Component }> | Divide un componente en código |
useTransition() | Ninguno | Devuelve [isPending, startTransition] para actualizaciones no bloqueantes |
startTransition(fn) | () => void | Marca actualizaciones de estado como no urgentes |
Variaciones
Suspense anidado para carga progresiva:
function ProductPage({ productId }: { productId: string }) {
return (
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails productId={productId} />
{/* Las reseñas se cargan independientemente, más tarde */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={productId} />
</Suspense>
</Suspense>
);
}Transmisión de componentes de servidor con Suspense (Next.js):
// app/page.tsx — Componente de servidor
export default function Page() {
return (
<main>
<h1>Panel de Control</h1>
<Suspense fallback={<ChartSkeleton />}>
{/* Este componente de servidor asincrónico se transmite cuando está listo */}
<RevenueChart />
</Suspense>
</main>
);
}
async function RevenueChart() {
const data = await getRevenueData(); // se ejecuta en servidor
return <Chart data={data} />;
}Notas de TypeScript
use<T>(promise: Promise<T>)devuelveT— TypeScript infiere correctamente el tipo resuelto.React.lazyespera que la importación devuelva{ default: ComponentType }. Las exportaciones nombradas necesitan un envoltorio:lazy(() => import('./Foo').then(m => ({ default: m.Foo }))).- El
fallbackde Suspense se escribe comoReactNodey aceptanull(no renderiza nada mientras se carga).
Trampas
-
Crear promesas durante el renderizado — Llamar a
fetch()dentro del cuerpo del componente crea una nueva promesa en cada renderizado, causando un bucle de suspensión infinito. Solución: Crea la promesa fuera del renderizado (en un manejador de eventos, padre o caché) y pásala como prop. -
ErrorBoundary faltante — Si una promesa suspendida se rechaza, el error se propaga hacia arriba. Sin un límite de error, todo el árbol se desmonta. Solución: Siempre empareja Suspense con un ErrorBoundary.
-
Carga en cascada — Los límites de Suspense anidados con obtención de datos secuencial causan cascadas (A se carga, luego B comienza). Solución: Inicia las obtenciones en paralelo y pasa promesas hacia abajo, o usa una biblioteca de datos que admita precarga en paralelo.
-
Destello del estado de carga — Las obtenciones rápidas de datos causan un breve destello del esqueleto. Solución: Usa
useTransitionpara mantener el contenido actual visible, o usauseDeferredValuepara valores derivados. -
Suspense no captura errores de manejadores de eventos — Solo se capturan las suspensiones de renderizado. Una función asincrónica en
onClickno dispara Suspense. Solución: Administra manualmente el estado asincrónico del manejador de eventos o mueve la obtención de datos a un recurso suspendible.
Alternativas
| Enfoque | Compensación |
|---|---|
Suspense + use() | Declarativo, componible; requiere disciplina de almacenamiento en caché de promesas |
useEffect + estado de carga | Manual pero explícito; boilerplate extenso |
| React Query / SWR | Almacenamiento en caché completo, revalidación, Suspense opcional; dependencia adicional |
loading.tsx de Next.js | Límite de Suspense a nivel de ruta; específico de Next.js |
| Interfaz esquelética sin Suspense | Enfoque solo CSS; sin integración de React |
Ejemplo del Mundo Real
De una aplicación SaaS de Next.js 15 / React 19 en producción (SystemsArchitect.io).
// Ejemplo de producción: página de destino de estudio con Suspense
// Archivo: src/app/study/page.tsx
import { Suspense } from "react";
import StudyServiceSelector from "@/components/study/study-service-selector";
export default async function StudyLandingPage() {
const availableServices = await getAvailableStudyServices();
const services = availableServices.map((service) => ({
slug: service.serviceSlug,
title: service.serviceTitle,
platform: service.platform,
basicsCount: service.categories.find((c) => c.category === "basics")?.cardCount || 0,
featuresCount: service.categories.find((c) => c.category === "features")?.cardCount || 0,
bestPracticesCount: service.categories.find((c) => c.category === "best-practices")?.cardCount || 0,
}));
const totalCards = availableServices.reduce((sum, s) => sum + s.totalCards, 0);
return (
<div className="relative min-h-screen">
<Suspense fallback={<div>Loading services...</div>}>
<StudyServiceSelector services={services} totalCards={totalCards} />
</Suspense>
</div>
);
}Lo que esto demuestra en producción:
- Los datos se obtienen por encima del límite de Suspense en el componente de servidor asincrónico
- Suspense aquí maneja principalmente el límite de hidratación del cliente para
StudyServiceSelector(un componente del cliente) - Los datos se pre-transforman en el servidor (mapeo y reducción) antes de pasar al componente del cliente, minimizando la carga serializada
- El fallback debe coincidir con el tamaño de diseño esperado para prevenir CLS. En producción, reemplaza el fallback de texto con un componente esquelético
- Los límites de Suspense pueden ser anidados. Un Suspense a nivel de página muestra un cargador de página completa, mientras que un Suspense a nivel de sección muestra spinners localizados
Preguntas Frecuentes
¿Qué hace un límite de Suspense?
- Cuando un componente dentro de un límite de Suspense se suspende (lanza una promesa), React muestra el
fallbacken lugar del subárbol suspendido. - Una vez que la promesa se resuelve, React vuelve a intentar renderizar el componente con los datos resueltos.
- Suspense reemplaza el estado manual
isLoadingcon límites de carga declarativos.
¿Cómo funciona el hook use() con Suspense en React 19?
function PostList({ postsPromise }: { postsPromise: Promise<Post[]> }) {
const posts = use(postsPromise);
return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}use()lee el valor de una promesa. Si la promesa aún no se ha resuelto, suspende el componente.- El límite de Suspense más cercano muestra su fallback hasta que la promesa se resuelve.
use()infiere correctamente el tipo resuelto en TypeScript.
¿Cuál es la diferencia entre React.lazy y use() para Suspense?
React.lazy()envuelve una importación dinámica y se suspende hasta que el módulo (código del componente) se carga. Es para división de código.use()lee datos de una promesa y se suspende hasta que los datos se resuelven. Es para obtención de datos.- Ambos disparan Suspense, pero sirven propósitos diferentes.
¿Cómo useTransition previene el destello del estado de carga?
useTransitionenvuelve una actualización de estado como no urgente, manteniendo la interfaz actual visible mientras se cargan los datos.- En lugar de mostrar inmediatamente el fallback de Suspense, React muestra el contenido anterior con un indicador
isPending. - Esto evita destellos de esqueleto desconcertantes para obtenciones rápidas de datos.
Trampa: ¿Por qué crear una promesa dentro del cuerpo de un componente causa un bucle infinito?
- Llamar a
fetch()dentro del cuerpo del renderizado crea una nueva promesa en cada renderizado. - La nueva promesa suspende el componente, que dispara un re-renderizado, que crea otra nueva promesa.
- Solución: crea la promesa fuera del renderizado (en un manejador de eventos, padre o caché) y pásala como prop.
Trampa: ¿Qué sucede si una promesa de Suspense se rechaza sin un ErrorBoundary?
- El error se propaga hacia arriba en el árbol de React. Sin un límite de error, todo el árbol se desmonta.
- Solución: siempre empareja Suspense con un ErrorBoundary arriba o alrededor de él.
<ErrorBoundary fallback={<p>Error</p>}>
<Suspense fallback={<Skeleton />}>
<AsyncComponent />
</Suspense>
</ErrorBoundary>¿Cómo los límites de Suspense anidados permiten carga progresiva?
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails productId={id} />
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={id} />
</Suspense>
</Suspense>- El límite externo muestra su fallback hasta que
ProductDetailsse carga. - El límite interno muestra independientemente su propio fallback para
ProductReviews. - El contenido crítico se carga primero; el contenido secundario se transmite más tarde.
¿Cómo escribes React.lazy para una exportación nombrada en TypeScript?
const Foo = lazy(() =>
import("./Foo").then((m) => ({ default: m.Foo }))
);React.lazyespera{ default: ComponentType }de la importación.- Para exportaciones nombradas, transforma el resultado de la importación para envolver la exportación nombrada como
default.
¿Cómo funciona Suspense con los componentes de servidor de Next.js para la transmisión?
- El servidor envía el HTML del fallback de Suspense primero, luego transmite el contenido resuelto cuando el componente de servidor asincrónico termina.
- Esto permite que el navegador renderice la página progresivamente sin esperar a que todos los datos se carguen.
- En Next.js, los archivos
loading.tsxcrean automáticamente límites de Suspense a nivel de ruta.
¿Puede Suspense capturar errores de manejadores de eventos o funciones asincrónicas onClick?
- No. Suspense solo captura suspensiones de renderizado (promesas lanzadas durante el renderizado).
- Una función asincrónica en
onClickno dispara Suspense. - Solución: administra manualmente el estado asincrónico del manejador de eventos o mueve la obtención de datos a un recurso suspendible.
¿Cuál es el tipo de fallback de Suspense y puede ser null?
fallbackse escribe comoReactNodey aceptanull, que no renderiza nada mientras se carga.- Usar
nulles apropiado cuando no quieres un indicador visual de carga (ej., datos precargados que se resuelven instantáneamente). - Para una buena experiencia de usuario, prefiere componentes esqueletales que coincidan con el tamaño de diseño esperado para prevenir CLS.
Relacionado
- Error Boundaries — Empareja con Suspense para un manejo completo de datos asincrónos
- Performance —
useTransitionyuseDeferredValuepara mantener la interfaz responsiva - State Machines — Enfoque alternativo para modelar estados de carga/error/éxito