Loading UI
Los estados de carga mejoran la experiencia del usuario mostrando algo mientras se obtienen los datos. NextJS 15 hace esto simple con el archivo loading.tsx
, que se muestra automáticamente mientras una página carga.
¿Qué es loading.tsx?
loading.tsx
es un archivo especial que se muestra mientras el contenido de page.tsx
se está cargando (obteniendo datos, procesando, etc.).
app/
└── productos/
├── page.tsx ← Contenido final
└── loading.tsx ← Se muestra mientras page.tsx carga
Flujo de carga:
- Usuario navega a
/productos
- NextJS muestra
loading.tsx
inmediatamente - En paralelo, obtiene datos y renderiza
page.tsx
- Cuando termina, reemplaza
loading.tsx
conpage.tsx
Basado en React Suspense
Internamente, NextJS usa React Suspense para manejar esto. loading.tsx
es automáticamente envuelto en un <Suspense>
boundary.
Sintaxis básica
Loading simple
// app/productos/loading.tsx
export default function Loading() {
return (
<div className="flex items-center justify-center h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
)
}
Eso es todo. Cuando alguien visite /productos
, verá este spinner mientras page.tsx
carga.
Loading con mensaje
// app/productos/loading.tsx
export default function Loading() {
return (
<div className="flex flex-col items-center justify-center h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<p className="mt-4 text-gray-600">Cargando productos...</p>
</div>
)
}
Alcance de loading.tsx
Un archivo loading.tsx
afecta a su ruta y todas las rutas anidadas debajo de él:
app/
└── productos/
├── loading.tsx ← Aplica a /productos y rutas anidadas
├── page.tsx ✓ Usa este loading
└── [id]/
└── page.tsx ✓ También usa este loading
Qué cubre:
/productos
→ muestraproductos/loading.tsx
/productos/123
→ muestraproductos/loading.tsx
Loading específico para rutas anidadas
Puedes tener diferentes loadings en diferentes niveles:
app/
└── productos/
├── loading.tsx ← Loading para /productos
├── page.tsx
└── [id]/
├── loading.tsx ← Loading para /productos/[id]
└── page.tsx
Comportamiento:
/productos
→ usaproductos/loading.tsx
/productos/123
→ usaproductos/[id]/loading.tsx
(más específico)
El loading más cercano a la página siempre gana.
Loading granular
Crea loadings específicos en rutas anidadas para experiencias más personalizadas. Un producto individual puede tener un skeleton diferente a la lista de productos.
Skeleton Screens
Los skeleton screens son mejores que spinners genéricos porque muestran la estructura de la página:
Skeleton de lista de productos
// app/productos/loading.tsx
export default function Loading() {
return (
<div className="container mx-auto px-4 py-8">
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="border rounded-lg p-4">
{/* Imagen */}
<div className="h-48 bg-gray-200 rounded animate-pulse mb-4"></div>
{/* Título */}
<div className="h-4 bg-gray-200 rounded animate-pulse mb-2"></div>
{/* Descripción */}
<div className="h-3 bg-gray-200 rounded animate-pulse mb-1"></div>
<div className="h-3 bg-gray-200 rounded animate-pulse w-2/3"></div>
{/* Precio */}
<div className="h-6 w-20 bg-gray-200 rounded animate-pulse mt-4"></div>
</div>
))}
</div>
</div>
)
}
Resultado: El usuario ve la estructura exacta de la página mientras carga.
Skeleton de producto individual
// app/productos/[id]/loading.tsx
export default function Loading() {
return (
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Imagen del producto */}
<div className="aspect-square bg-gray-200 rounded-lg animate-pulse"></div>
{/* Info del producto */}
<div>
{/* Título */}
<div className="h-8 bg-gray-200 rounded animate-pulse mb-4"></div>
{/* Precio */}
<div className="h-10 w-32 bg-gray-200 rounded animate-pulse mb-6"></div>
{/* Descripción */}
<div className="space-y-2 mb-6">
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 bg-gray-200 rounded animate-pulse w-3/4"></div>
</div>
{/* Botones */}
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
</div>
</div>
</div>
)
}
Componente reutilizable de Skeleton
// components/Skeleton.tsx
interface SkeletonProps {
className?: string
}
export function Skeleton({ className = '' }: SkeletonProps) {
return (
<div className={`bg-gray-200 rounded animate-pulse ${className}`}></div>
)
}
// Uso
// app/productos/loading.tsx
import { Skeleton } from '@/components/Skeleton'
export default function Loading() {
return (
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-8 w-48 mb-6" />
<div className="grid grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="border rounded-lg p-4">
<Skeleton className="h-48 mb-4" />
<Skeleton className="h-4 mb-2" />
<Skeleton className="h-3 mb-1" />
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-6 w-20 mt-4" />
</div>
))}
</div>
</div>
)
}
Skeleton vs Spinner
Skeleton screens son mejores porque:
- Muestran la estructura de la página
- Reducen la percepción de tiempo de carga
- Se sienten más "premium"
- Dan contexto sobre qué está cargando
Usa spinners solo para acciones rápidas (enviar formulario, etc.).
Streaming con Suspense
En lugar de esperar a que TODO cargue, puedes hacer streaming de partes de la página:
Streaming manual con Suspense
// app/productos/[id]/page.tsx
import { Suspense } from 'react'
// Componente que carga datos
async function ProductoInfo({ id }: { id: string }) {
const producto = await obtenerProducto(id) // Operación lenta
return (
<div>
<h1>{producto.nombre}</h1>
<p>{producto.descripcion}</p>
</div>
)
}
// Componente que carga datos
async function Resenas({ id }: { id: string }) {
const resenas = await obtenerResenas(id) // Operación MUY lenta
return (
<div>
<h2>Reseñas</h2>
{resenas.map(resena => (
<div key={resena.id}>{resena.texto}</div>
))}
</div>
)
}
// Página principal
export default function ProductoPage({
params,
}: {
params: { id: string }
}) {
return (
<div>
{/* ProductoInfo se muestra cuando está listo */}
<Suspense fallback={<div>Cargando producto...</div>}>
<ProductoInfo id={params.id} />
</Suspense>
{/* Reseñas se muestran cuando están listas (independiente) */}
<Suspense fallback={<div>Cargando reseñas...</div>}>
<Resenas id={params.id} />
</Suspense>
</div>
)
}
Qué sucede:
- La página se muestra inmediatamente con los fallbacks
- Cuando
ProductoInfo
termina de cargar → se reemplaza su fallback - Cuando
Resenas
termina → se reemplaza su fallback - Todo es independiente, no se bloquean entre sí
Streaming con skeletons específicos
// app/productos/[id]/page.tsx
import { Suspense } from 'react'
function ProductoSkeleton() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded mb-4"></div>
<div className="h-4 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-2/3"></div>
</div>
)
}
function ResenasSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-4 bg-gray-200 rounded mb-2"></div>
<div className="h-3 bg-gray-200 rounded"></div>
</div>
))}
</div>
)
}
export default function ProductoPage({ params }: { params: { id: string } }) {
return (
<div className="container mx-auto px-4 py-8">
<Suspense fallback={<ProductoSkeleton />}>
<ProductoInfo id={params.id} />
</Suspense>
<div className="mt-12">
<Suspense fallback={<ResenasSkeleton />}>
<Resenas id={params.id} />
</Suspense>
</div>
</div>
)
}
¿Cuándo usar Suspense manual?
Usa Suspense
manual cuando:
- Tienes partes de la página que cargan a diferentes velocidades
- Quieres mostrar contenido importante primero
- Algunas secciones son opcionales (reseñas, productos relacionados)
Usa loading.tsx
cuando toda la página debe cargar junta.
Layouts y Loading
Los layouts NO son afectados por loading.tsx
. El loading solo afecta el contenido de la página:
app/
└── productos/
├── layout.tsx ← Siempre visible (header, sidebar)
├── loading.tsx ← Solo afecta el contenido
└── page.tsx ← Contenido que se reemplaza
Flujo visual:
┌─────────────────────────────────┐
│ Layout (header, sidebar) │ ← Siempre visible
│ ┌───────────────────────────┐ │
│ │ │ │
│ │ Loading.tsx muestra │ │ ← Se muestra mientras carga
│ │ aquí │ │
│ │ │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
Después:
┌─────────────────────────────────┐
│ Layout (header, sidebar) │ ← Sigue visible
│ ┌───────────────────────────┐ │
│ │ │ │
│ │ Page.tsx se muestra │ │ ← Reemplaza a loading.tsx
│ │ aquí │ │
│ │ │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
Ejemplo: Dashboard con sidebar
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="flex">
{/* Sidebar siempre visible */}
<aside className="w-64 bg-gray-100 p-4">
<nav>
<a href="/dashboard">Inicio</a>
<a href="/dashboard/ventas">Ventas</a>
<a href="/dashboard/productos">Productos</a>
</nav>
</aside>
{/* Contenido principal (aquí se muestra loading.tsx) */}
<main className="flex-1 p-8">
{children}
</main>
</div>
)
}
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded mb-6 w-48"></div>
<div className="h-64 bg-gray-200 rounded"></div>
</div>
)
}
Cuando navegues entre páginas del dashboard, el sidebar se mantiene y solo el contenido muestra el loading.
Patrones comunes de Loading
1. Lista de items (productos, posts)
// app/productos/loading.tsx
export default function Loading() {
return (
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse"></div>
<div className="h-10 w-32 bg-gray-200 rounded animate-pulse"></div>
</div>
{/* Grid de productos */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="border rounded-lg overflow-hidden">
<div className="h-48 bg-gray-200 animate-pulse"></div>
<div className="p-4 space-y-2">
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 bg-gray-200 rounded animate-pulse w-2/3"></div>
<div className="h-6 bg-gray-200 rounded animate-pulse w-24 mt-2"></div>
</div>
</div>
))}
</div>
</div>
)
}
2. Detalle de producto
// app/productos/[id]/loading.tsx
export default function Loading() {
return (
<div className="container mx-auto px-4 py-8">
{/* Breadcrumbs */}
<div className="flex space-x-2 mb-6">
<div className="h-4 w-16 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 w-4 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 w-24 bg-gray-200 rounded animate-pulse"></div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Imagen */}
<div className="aspect-square bg-gray-200 rounded-lg animate-pulse"></div>
{/* Info */}
<div>
<div className="h-10 bg-gray-200 rounded animate-pulse mb-4"></div>
<div className="h-8 w-32 bg-gray-200 rounded animate-pulse mb-6"></div>
<div className="space-y-2 mb-6">
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 bg-gray-200 rounded animate-pulse w-3/4"></div>
</div>
<div className="h-12 bg-gray-200 rounded animate-pulse w-full"></div>
</div>
</div>
</div>
)
}
3. Tabla de datos
// app/dashboard/ventas/loading.tsx
export default function Loading() {
return (
<div>
{/* Header */}
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse mb-6"></div>
{/* Filtros */}
<div className="flex space-x-4 mb-6">
<div className="h-10 w-32 bg-gray-200 rounded animate-pulse"></div>
<div className="h-10 w-32 bg-gray-200 rounded animate-pulse"></div>
<div className="h-10 w-32 bg-gray-200 rounded animate-pulse"></div>
</div>
{/* Tabla */}
<div className="border rounded-lg overflow-hidden">
{/* Header de tabla */}
<div className="bg-gray-50 border-b p-4 flex space-x-4">
<div className="h-4 w-24 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 w-32 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 w-20 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 w-24 bg-gray-200 rounded animate-pulse"></div>
</div>
{/* Filas */}
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="border-b p-4 flex space-x-4">
<div className="h-4 w-24 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 w-32 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 w-20 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 w-24 bg-gray-200 rounded animate-pulse"></div>
</div>
))}
</div>
</div>
)
}
4. Blog post
// app/blog/[slug]/loading.tsx
export default function Loading() {
return (
<article className="container max-w-3xl mx-auto px-4 py-8">
{/* Título */}
<div className="h-12 bg-gray-200 rounded animate-pulse mb-4"></div>
<div className="h-12 bg-gray-200 rounded animate-pulse w-2/3 mb-8"></div>
{/* Metadata */}
<div className="flex items-center space-x-4 mb-8">
<div className="h-10 w-10 bg-gray-200 rounded-full animate-pulse"></div>
<div>
<div className="h-4 w-32 bg-gray-200 rounded animate-pulse mb-2"></div>
<div className="h-3 w-24 bg-gray-200 rounded animate-pulse"></div>
</div>
</div>
{/* Imagen destacada */}
<div className="h-96 bg-gray-200 rounded-lg animate-pulse mb-8"></div>
{/* Contenido */}
<div className="space-y-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-4 bg-gray-200 rounded animate-pulse"></div>
))}
<div className="h-4 bg-gray-200 rounded animate-pulse w-3/4"></div>
</div>
</article>
)
}
Loading con Route Groups
Los Route Groups afectan dónde se aplica el loading:
app/
├── (marketing)/
│ ├── layout.tsx
│ ├── loading.tsx ← Solo para rutas de marketing
│ ├── sobre-nosotros/
│ │ └── page.tsx
│ └── contacto/
│ └── page.tsx
│
└── (tienda)/
├── layout.tsx
├── loading.tsx ← Solo para rutas de tienda
├── productos/
│ └── page.tsx
└── carrito/
└── page.tsx
Cada Route Group puede tener su propio loading personalizado.
Errores comunes y soluciones
1. Loading no se muestra
// ❌ Incorrecto: No es async, no hay tiempo de carga
export default function Page() {
const data = staticData // Datos inmediatos
return <div>{data}</div>
}
// ✓ Correcto: Operación async, loading se muestra
export default async function Page() {
const data = await fetchData() // Operación que toma tiempo
return <div>{data}</div>
}
loading.tsx
solo se muestra cuando hay operaciones asíncronas (fetching de datos, etc.). Si tu página es estática o muy rápida, puede que no veas el loading.
2. Layout se oculta con loading
// ❌ Incorrecto: Loading muy grande oculta todo
export default function Loading() {
return (
<div className="fixed inset-0 bg-white"> {/* Cubre todo */}
<div>Loading...</div>
</div>
)
}
// ✓ Correcto: Loading respeta el layout
export default function Loading() {
return (
<div className="p-8"> {/* Solo el contenido */}
<div>Loading...</div>
</div>
)
}
3. No coincidir con el diseño final
// ❌ Incorrecto: Skeleton muy diferente al diseño real
export default function Loading() {
return <div>Loading...</div> // Muy simple
}
// ✓ Correcto: Skeleton refleja la estructura real
export default function Loading() {
return (
<div className="grid grid-cols-3 gap-6"> {/* Igual que la página real */}
{Array.from({ length: 6 }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
)
}
4. Animaciones bruscas
// ❌ Sin transición suave
<div className="bg-gray-200"></div>
// ✓ Con animación pulse
<div className="bg-gray-200 animate-pulse"></div>
5. Loading muy detallado
// ❌ Demasiado detalle en el skeleton
<div>
<div className="h-4 w-24 bg-gray-200"></div>
<div className="h-3 w-32 bg-gray-200"></div>
<div className="h-3 w-28 bg-gray-200"></div>
<div className="h-2 w-16 bg-gray-200"></div>
{/* 50 líneas más... */}
</div>
// ✓ Balance entre detalle y simplicidad
<div>
<div className="h-4 w-24 bg-gray-200 mb-2"></div>
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-3 bg-gray-200"></div>
))}
</div>
</div>
Mejores prácticas
1. Skeleton que refleja la UI final
Tu skeleton debe tener la misma estructura que el contenido final:
// Página final
<div className="grid grid-cols-3 gap-6">
{productos.map(p => <ProductCard producto={p} />)}
</div>
// Loading debe usar la misma estructura
<div className="grid grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => <SkeletonCard key={i} />)}
</div>
2. Usa Suspense para partes independientes
// ✓ Bueno: Partes independientes
<div>
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
<Suspense fallback={<ProductsSkeleton />}>
<Products />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
</Suspense>
</div>
3. Loading específico por ruta
app/
└── productos/
├── loading.tsx ← Lista de productos
└── [id]/
└── loading.tsx ← Detalle de producto (diferente)
4. Reutiliza componentes de skeleton
// components/skeletons/ProductCardSkeleton.tsx
export function ProductCardSkeleton() {
return (
<div className="border rounded-lg p-4">
<div className="h-48 bg-gray-200 rounded animate-pulse mb-4"></div>
<div className="h-4 bg-gray-200 rounded animate-pulse mb-2"></div>
<div className="h-3 bg-gray-200 rounded animate-pulse w-2/3"></div>
</div>
)
}
// Usar en loading.tsx
import { ProductCardSkeleton } from '@/components/skeletons/ProductCardSkeleton'
export default function Loading() {
return (
<div className="grid grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<ProductCardSkeleton key={i} />
))}
</div>
)
}
5. Animación consistente
// Define una clase global para consistencia
// globals.css
.skeleton {
@apply bg-gray-200 animate-pulse rounded;
}
// Uso
<div className="skeleton h-4 w-32"></div>
6. Cantidad correcta de skeletons
// ✓ Muestra cantidad realista según viewport
export default function Loading() {
return (
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6">
{/* Muestra ~8-12 items para llenar la pantalla */}
{Array.from({ length: 12 }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
)
}
Testing de Loading States
Puedes probar loading states agregando delays artificiales:
// app/productos/page.tsx
export default async function ProductosPage() {
// Solo para desarrollo: simular carga lenta
if (process.env.NODE_ENV === 'development') {
await new Promise(resolve => setTimeout(resolve, 2000))
}
const productos = await obtenerProductos()
return <ProductosGrid productos={productos} />
}
O usa las DevTools de React:
// Envolver en Suspense con delay
<Suspense fallback={<Loading />}>
{/* En DevTools puedes "pausar" para ver el loading */}
<ProductosContent />
</Suspense>
Ejemplo completo: E-commerce
app/
└── productos/
├── layout.tsx
├── loading.tsx ← Lista
├── page.tsx
└── [slug]/
├── loading.tsx ← Detalle
└── page.tsx
Lista de productos:
// app/productos/loading.tsx
import { ProductCardSkeleton } from '@/components/skeletons'
export default function Loading() {
return (
<div className="container mx-auto px-4 py-8">
{/* Header con filtros */}
<div className="flex justify-between items-center mb-8">
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse"></div>
<div className="flex space-x-4">
<div className="h-10 w-32 bg-gray-200 rounded animate-pulse"></div>
<div className="h-10 w-32 bg-gray-200 rounded animate-pulse"></div>
</div>
</div>
{/* Grid de productos */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6">
{Array.from({ length: 12 }).map((_, i) => (
<ProductCardSkeleton key={i} />
))}
</div>
</div>
)
}
Detalle de producto:
// app/productos/[slug]/loading.tsx
export default function Loading() {
return (
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Galería */}
<div className="space-y-4">
<div className="aspect-square bg-gray-200 rounded-lg animate-pulse"></div>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="aspect-square bg-gray-200 rounded animate-pulse"></div>
))}
</div>
</div>
{/* Info */}
<div>
<div className="h-10 bg-gray-200 rounded animate-pulse mb-4"></div>
<div className="h-8 w-32 bg-gray-200 rounded animate-pulse mb-6"></div>
<div className="space-y-2 mb-8">
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
<div className="h-4 bg-gray-200 rounded animate-pulse w-3/4"></div>
</div>
<div className="space-y-4 mb-8">
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
</div>
<div className="h-14 bg-gray-200 rounded-lg animate-pulse"></div>
</div>
</div>
{/* Tabs */}
<div className="mt-12">
<div className="flex space-x-4 border-b mb-6">
<div className="h-10 w-32 bg-gray-200 rounded animate-pulse"></div>
<div className="h-10 w-32 bg-gray-200 rounded animate-pulse"></div>
</div>
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-4 bg-gray-200 rounded animate-pulse"></div>
))}
</div>
</div>
</div>
)
}
Con streaming para reseñas:
// app/productos/[slug]/page.tsx
import { Suspense } from 'react'
async function ProductoInfo({ slug }: { slug: string }) {
const producto = await obtenerProducto(slug)
return <ProductoDetalle producto={producto} />
}
async function Resenas({ slug }: { slug: string }) {
const resenas = await obtenerResenas(slug) // Operación lenta
return <ResenasLista resenas={resenas} />
}
function ResenasSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="border rounded p-4 animate-pulse">
<div className="flex items-center space-x-2 mb-2">
<div className="h-8 w-8 bg-gray-200 rounded-full"></div>
<div className="h-4 w-32 bg-gray-200 rounded"></div>
</div>
<div className="space-y-2">
<div className="h-3 bg-gray-200 rounded"></div>
<div className="h-3 bg-gray-200 rounded w-3/4"></div>
</div>
</div>
))}
</div>
)
}
export default function ProductoPage({ params }: { params: { slug: string } }) {
return (
<div className="container mx-auto px-4 py-8">
{/* Info del producto carga primero */}
<Suspense fallback={<div>Cargando producto...</div>}>
<ProductoInfo slug={params.slug} />
</Suspense>
{/* Reseñas cargan independientemente */}
<div className="mt-12">
<h2 className="text-2xl font-bold mb-6">Reseñas</h2>
<Suspense fallback={<ResenasSkeleton />}>
<Resenas slug={params.slug} />
</Suspense>
</div>
</div>
)
}
Resumen
Puntos clave sobre Loading UI:
loading.tsx
se muestra automáticamente mientraspage.tsx
carga- Funciona con React Suspense bajo el capó
- Afecta su ruta y todas las rutas anidadas
- El layout NO es afectado, solo el contenido
- Skeleton screens son mejores que spinners genéricos
- Usa
<Suspense>
manual para streaming granular - El skeleton debe reflejar la estructura de la UI final
- Reutiliza componentes de skeleton para consistencia
- Usa
animate-pulse
de Tailwind para animaciones suaves - Puedes tener diferentes loadings en diferentes niveles de ruta
Decisiones de diseño:
Caso | Usar |
---|---|
Toda la página carga junta | loading.tsx |
Partes cargan a diferente velocidad | <Suspense> manual |
Acción rápida (< 1s) | Spinner |
Página completa | Skeleton screen |
Lista de items | Grid de skeleton cards |
Contenido importante primero | Streaming con Suspense |