Carga y Rendimiento en Next.js

Next.js 13+ con App Router ofrece herramientas avanzadas para manejar estados de carga y optimizar el rendimiento. Aprende a crear experiencias fluidas mientras se cargan los datos.

Archivos Loading

Los archivos loading.tsx se muestran instantáneamente mientras se cargan las páginas o componentes.

Loading Básico

typescript
// app/loading.tsx - Loading global
export default function Loading() {
return (
  <div className="flex items-center justify-center min-h-screen">
    <div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-600"></div>
  </div>
)
}

// app/dashboard/loading.tsx - Loading específico
export default function DashboardLoading() {
return (
  <div className="p-6">
    <div className="animate-pulse space-y-4">
      <div className="h-8 bg-gray-200 rounded w-1/4"></div>
      <div className="h-4 bg-gray-200 rounded w-1/2"></div>
      <div className="grid grid-cols-3 gap-4 mt-6">
        {[...Array(6)].map((_, i) => (
          <div key={i} className="h-32 bg-gray-200 rounded"></div>
        ))}
      </div>
    </div>
  </div>
)
}

Loading con Skeleton

typescript
// components/Skeleton.tsx
export function CardSkeleton() {
return (
  <div className="bg-white p-6 rounded-lg shadow animate-pulse">
    <div className="flex items-center space-x-4">
      <div className="rounded-full bg-gray-200 h-12 w-12"></div>
      <div className="flex-1 space-y-2">
        <div className="h-4 bg-gray-200 rounded w-3/4"></div>
        <div className="h-3 bg-gray-200 rounded w-1/2"></div>
      </div>
    </div>
    <div className="mt-4 space-y-2">
      <div className="h-3 bg-gray-200 rounded"></div>
      <div className="h-3 bg-gray-200 rounded w-5/6"></div>
    </div>
  </div>
)
}

// app/posts/loading.tsx
import { CardSkeleton } from '@/components/Skeleton'

export default function PostsLoading() {
return (
  <div className="container mx-auto px-4 py-8">
    <div className="animate-pulse mb-8">
      <div className="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
      <div className="h-4 bg-gray-200 rounded w-1/2"></div>
    </div>
    
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
      {[...Array(9)].map((_, i) => (
        <CardSkeleton key={i} />
      ))}
    </div>
  </div>
)
}
 

Los archivos loading.tsx se renderizan instantáneamente desde el servidor, mejorando la percepción de velocidad sin JavaScript adicional.

Streaming de Datos

El streaming permite mostrar partes de la página mientras otras se cargan, usando React Suspense.

Suspense Básico

typescript
// app/dashboard/page.tsx
import { Suspense } from 'react'
import Stats from '@/components/Stats'
import RecentOrders from '@/components/RecentOrders'
import { CardSkeleton } from '@/components/Skeleton'

export default function DashboardPage() {
return (
  <div className="space-y-6">
    <h1 className="text-2xl font-bold">Dashboard</h1>
    
    {/* Se carga inmediatamente */}
    <Suspense fallback={<StatsLoading />}>
      <Stats />
    </Suspense>
    
    {/* Se carga independientemente */}
    <Suspense fallback={<OrdersLoading />}>
      <RecentOrders />
    </Suspense>
  </div>
)
}

function StatsLoading() {
return (
  <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
    {[...Array(4)].map((_, i) => (
      <div key={i} className="bg-white p-6 rounded-lg shadow animate-pulse">
        <div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
        <div className="h-8 bg-gray-200 rounded w-3/4"></div>
      </div>
    ))}
  </div>
)
}

function OrdersLoading() {
return (
  <div className="bg-white rounded-lg shadow">
    <div className="p-6 border-b">
      <div className="h-6 bg-gray-200 rounded w-1/4 animate-pulse"></div>
    </div>
    <div className="divide-y">
      {[...Array(5)].map((_, i) => (
        <div key={i} className="p-4 animate-pulse">
          <div className="flex items-center space-x-4">
            <div className="h-10 w-10 bg-gray-200 rounded-full"></div>
            <div className="flex-1 space-y-2">
              <div className="h-4 bg-gray-200 rounded w-1/3"></div>
              <div className="h-3 bg-gray-200 rounded w-1/4"></div>
            </div>
          </div>
        </div>
      ))}
    </div>
  </div>
)
}

Componentes con Datos Asincrónicos

typescript
// components/Stats.tsx
async function getStats() {
// Simulación de delay de API
await new Promise(resolve => setTimeout(resolve, 2000))

return {
  users: 2651,
  sales: 45231,
  orders: 1423,
  conversion: 3.24
}
}

export default async function Stats() {
const stats = await getStats()

return (
  <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
    <StatCard title="Usuarios" value={stats.users.toLocaleString()} />
    <StatCard title="Ventas" value={`$${stats.sales.toLocaleString()}`} />
    <StatCard title="Pedidos" value={stats.orders.toLocaleString()} />
    <StatCard title="Conversión" value={`${stats.conversion}%`} />
  </div>
)
}

function StatCard({ title, value }: { title: string; value: string }) {
return (
  <div className="bg-white p-6 rounded-lg shadow">
    <p className="text-sm text-gray-600">{title}</p>
    <p className="text-2xl font-semibold text-gray-900">{value}</p>
  </div>
)
}

// components/RecentOrders.tsx
async function getRecentOrders() {
await new Promise(resolve => setTimeout(resolve, 3000))

return [
  { id: 1, customer: 'Juan Pérez', amount: 299.99, status: 'Completado' },
  { id: 2, customer: 'María García', amount: 159.50, status: 'Pendiente' },
  { id: 3, customer: 'Carlos López', amount: 89.99, status: 'Enviado' },
]
}

export default async function RecentOrders() {
const orders = await getRecentOrders()

return (
  <div className="bg-white rounded-lg shadow">
    <div className="p-6 border-b">
      <h2 className="text-lg font-semibold">Pedidos Recientes</h2>
    </div>
    <div className="divide-y">
      {orders.map((order) => (
        <div key={order.id} className="p-4 flex justify-between items-center">
          <div>
            <p className="font-medium">{order.customer}</p>
            <p className="text-sm text-gray-600">${order.amount}</p>
          </div>
          <span className={`px-2 py-1 text-xs rounded-full ${
            order.status === 'Completado' 
              ? 'bg-green-100 text-green-800'
              : order.status === 'Pendiente'
              ? 'bg-yellow-100 text-yellow-800'
              : 'bg-blue-100 text-blue-800'
          }`}>
            {order.status}
          </span>
        </div>
      ))}
    </div>
  </div>
)
}
 

Suspense permite que diferentes partes de la página se carguen independientemente, mejorando la experiencia del usuario al mostrar contenido progresivamente.

Tipos de Loading Avanzados

Loading Progresivo

typescript
// components/ProgressiveLoader.tsx
'use client'
import { useState, useEffect } from 'react'

export default function ProgressiveLoader() {
const [progress, setProgress] = useState(0)

useEffect(() => {
  const timer = setInterval(() => {
    setProgress(prev => {
      if (prev >= 100) {
        clearInterval(timer)
        return 100
      }
      return prev + Math.random() * 15
    })
  }, 200)
  
  return () => clearInterval(timer)
}, [])

return (
  <div className="flex flex-col items-center justify-center min-h-screen">
    <div className="w-64 bg-gray-200 rounded-full h-2 mb-4">
      <div 
        className="bg-blue-600 h-2 rounded-full transition-all duration-300"
        style={{ width: `${Math.min(progress, 100)}%` }}
      ></div>
    </div>
    <p className="text-gray-600">
      Cargando... {Math.round(Math.min(progress, 100))}%
    </p>
  </div>
)
}

// app/upload/loading.tsx
import ProgressiveLoader from '@/components/ProgressiveLoader'

export default function UploadLoading() {
return <ProgressiveLoader />
}

Loading con Mensajes Dinámicos

typescript
// components/DynamicLoader.tsx
'use client'
import { useState, useEffect } from 'react'

const loadingMessages = [
'Preparando datos...',
'Conectando con el servidor...',
'Procesando información...',
'Casi listo...',
]

export default function DynamicLoader() {
const [messageIndex, setMessageIndex] = useState(0)

useEffect(() => {
  const timer = setInterval(() => {
    setMessageIndex(prev => (prev + 1) % loadingMessages.length)
  }, 1500)
  
  return () => clearInterval(timer)
}, [])

return (
  <div className="flex flex-col items-center justify-center min-h-screen">
    <div className="relative">
      <div className="animate-spin rounded-full h-16 w-16 border-4 border-blue-200 border-t-blue-600"></div>
      <div className="absolute inset-0 flex items-center justify-center">
        <div className="w-2 h-2 bg-blue-600 rounded-full animate-pulse"></div>
      </div>
    </div>
    <p className="mt-4 text-gray-600 animate-pulse">
      {loadingMessages[messageIndex]}
    </p>
  </div>
)
}

Lazy Loading y Optimizaciones

Lazy Loading de Componentes

typescript
// app/dashboard/page.tsx
import { lazy, Suspense } from 'react'
import { CardSkeleton } from '@/components/Skeleton'

// Lazy loading de componentes pesados
const HeavyChart = lazy(() => import('@/components/HeavyChart'))
const DataTable = lazy(() => import('@/components/DataTable'))

export default function DashboardPage() {
return (
  <div className="space-y-6">
    <h1 className="text-2xl font-bold">Dashboard</h1>
    
    {/* Componente que se carga solo cuando es visible */}
    <Suspense fallback={<ChartSkeleton />}>
      <HeavyChart />
    </Suspense>
    
    {/* Tabla que se carga bajo demanda */}
    <Suspense fallback={<TableSkeleton />}>
      <DataTable />
    </Suspense>
  </div>
)
}

function ChartSkeleton() {
return (
  <div className="bg-white p-6 rounded-lg shadow animate-pulse">
    <div className="h-6 bg-gray-200 rounded w-1/4 mb-4"></div>
    <div className="h-64 bg-gray-200 rounded"></div>
  </div>
)
}

function TableSkeleton() {
return (
  <div className="bg-white rounded-lg shadow overflow-hidden animate-pulse">
    <div className="p-4 border-b">
      <div className="h-6 bg-gray-200 rounded w-1/3"></div>
    </div>
    {[...Array(5)].map((_, i) => (
      <div key={i} className="p-4 border-b flex space-x-4">
        <div className="h-4 bg-gray-200 rounded flex-1"></div>
        <div className="h-4 bg-gray-200 rounded w-20"></div>
        <div className="h-4 bg-gray-200 rounded w-16"></div>
      </div>
    ))}
  </div>
)
}

Intersection Observer para Lazy Loading

typescript
// hooks/useIntersectionObserver.ts
'use client'
import { useEffect, useRef, useState } from 'react'

export function useIntersectionObserver(options = {}) {
const [isIntersecting, setIsIntersecting] = useState(false)
const [hasIntersected, setHasIntersected] = useState(false)
const elementRef = useRef<HTMLDivElement>(null)

useEffect(() => {
  const element = elementRef.current
  if (!element) return
  
  const observer = new IntersectionObserver(([entry]) => {
    setIsIntersecting(entry.isIntersecting)
    if (entry.isIntersecting && !hasIntersected) {
      setHasIntersected(true)
    }
  }, options)
  
  observer.observe(element)
  
  return () => observer.disconnect()
}, [hasIntersected, options])

return { elementRef, isIntersecting, hasIntersected }
}

// components/LazySection.tsx
'use client'
import { useIntersectionObserver } from '@/hooks/useIntersectionObserver'
import { Suspense } from 'react'

interface LazySection {
children: React.ReactNode
fallback?: React.ReactNode
className?: string
}

export default function LazySection({ 
children, 
fallback = <div className="h-64 bg-gray-100 animate-pulse rounded"></div>,
className = ""
}: LazySection) {
const { elementRef, hasIntersected } = useIntersectionObserver({
  threshold: 0.1,
  rootMargin: '50px'
})

return (
  <div ref={elementRef} className={className}>
    {hasIntersected ? (
      <Suspense fallback={fallback}>
        {children}
      </Suspense>
    ) : (
      fallback
    )}
  </div>
)
}

Error Boundaries y Manejo de Errores

Error Boundary Básico

typescript
// app/error.tsx - Error global
'use client'

export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
  <div className="flex flex-col items-center justify-center min-h-screen">
    <div className="text-center">
      <h2 className="text-2xl font-bold text-gray-900 mb-4">
        ¡Algo salió mal!
      </h2>
      <p className="text-gray-600 mb-6">
        Ha ocurrido un error inesperado. Por favor, inténtalo de nuevo.
      </p>
      <button
        onClick={reset}
        className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
      >
        Intentar de nuevo
      </button>
    </div>
  </div>
)
}

// app/dashboard/error.tsx - Error específico
'use client'

export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
  <div className="bg-red-50 border border-red-200 rounded-lg p-6">
    <div className="flex items-center">
      <div className="flex-shrink-0">
        <svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
          <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
        </svg>
      </div>
      <div className="ml-3">
        <h3 className="text-sm font-medium text-red-800">
          Error en el Dashboard
        </h3>
        <p className="mt-1 text-sm text-red-700">
          No se pudieron cargar los datos del dashboard.
        </p>
        <div className="mt-4">
          <button
            onClick={reset}
            className="bg-red-600 text-white px-4 py-2 text-sm rounded hover:bg-red-700"
          >
            Recargar Dashboard
          </button>
        </div>
      </div>
    </div>
  </div>
)
}

Error Boundary con Retry Logic

typescript
// components/ErrorBoundary.tsx
'use client'
import { useState } from 'react'

interface ErrorBoundaryProps {
children: React.ReactNode
fallback?: React.ComponentType<{ error: Error; retry: () => void }>
}

export default function ErrorBoundary({ 
children, 
fallback: Fallback = DefaultErrorFallback 
}: ErrorBoundaryProps) {
const [error, setError] = useState<Error | null>(null)
const [retryCount, setRetryCount] = useState(0)

const retry = () => {
  setError(null)
  setRetryCount(prev => prev + 1)
}

if (error) {
  return <Fallback error={error} retry={retry} />
}

return (
  <div key={retryCount}>
    {children}
  </div>
)
}

function DefaultErrorFallback({ 
error, 
retry 
}: { 
error: Error
retry: () => void 
}) {
return (
  <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
    <div className="flex">
      <div className="ml-3">
        <h3 className="text-sm font-medium text-yellow-800">
          Error de Carga
        </h3>
        <p className="mt-1 text-sm text-yellow-700">
          {error.message}
        </p>
        <div className="mt-3">
          <button
            onClick={retry}
            className="bg-yellow-600 text-white px-3 py-1 text-sm rounded hover:bg-yellow-700"
          >
            Reintentar
          </button>
        </div>
      </div>
    </div>
  </div>
)
}

Optimizaciones de Performance

Preloading de Rutas

typescript
// components/PreloadLink.tsx
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'

interface PreloadLinkProps {
href: string
children: React.ReactNode
className?: string
preloadDelay?: number
}

export default function PreloadLink({ 
href, 
children, 
className,
preloadDelay = 1000 
}: PreloadLinkProps) {
const router = useRouter()

const handleMouseEnter = () => {
  setTimeout(() => {
    router.prefetch(href)
  }, preloadDelay)
}

return (
  <Link 
    href={href} 
    className={className}
    onMouseEnter={handleMouseEnter}
  >
    {children}
  </Link>
)
}

Memoización de Componentes

typescript
// components/OptimizedCard.tsx
import { memo } from 'react'

interface CardProps {
title: string
description: string
data: any[]
}

const OptimizedCard = memo(function Card({ 
title, 
description, 
data 
}: CardProps) {
return (
  <div className="bg-white p-6 rounded-lg shadow">
    <h3 className="text-lg font-semibold mb-2">{title}</h3>
    <p className="text-gray-600 mb-4">{description}</p>
    <div className="space-y-2">
      {data.map((item, index) => (
        <div key={item.id || index} className="p-2 bg-gray-50 rounded">
          {item.name}
        </div>
      ))}
    </div>
  </div>
)
})

export default OptimizedCard
 

Usa React.memo para componentes que reciben las mismas props frecuentemente. Combina con useMemo y useCallback para optimizaciones más profundas.

 

Los archivos loading.tsx se muestran mientras se resuelven los Server Components. Úsalos para crear skeletons que coincidan con el layout final.

 

Suspense permite streaming de HTML, mostrando contenido progresivamente. Esto mejora significativamente la percepción de velocidad en aplicaciones con datos complejos.