Streaming

En el capítulo anterior, aprendiste sobre los diferentes métodos de renderizado de Next.js. También discutimos cómo las solicitudes de datos lentas pueden afectar el rendimiento de tu aplicación. Veamos cómo puedes mejorar la experiencia del usuario cuando hay solicitudes de datos lentas.

¿Qué es el streaming?

El streaming es una técnica de transferencia de datos que te permite dividir una ruta en "fragmentos" más pequeños y transmitirlos progresivamente desde el servidor al cliente a medida que estén listos.

Diagrama que muestra el tiempo con obtención de datos secuencial y obtención de datos en paralelo

Con el streaming, puedes evitar que las solicitudes de datos lentas bloqueen toda tu página. Esto permite al usuario ver e interactuar con partes de la página sin tener que esperar a que todos los datos se carguen antes de que se muestre cualquier interfaz de usuario.

Diagrama que muestra el tiempo con obtención de datos secuencial y obtención de datos en paralelo

El streaming funciona bien con el modelo de componentes de React, ya que cada componente puede considerarse un fragmento.

Hay dos formas de implementar el streaming en Next.js:

  1. A nivel de página, con el archivo loading.tsx (que crea <Suspense> por ti).
  2. A nivel de componente, con <Suspense> para un control más granular.

Veamos cómo funciona esto.

Transmitiendo una página completa con loading.tsx

En la carpeta /app/dashboard, crea un nuevo archivo llamado loading.tsx:

/app/dashboard/loading.tsx
export default function Loading() {
  return <div>Cargando...</div>;
}

Actualiza http://localhost:3000/dashboard, y ahora deberías ver:

Página del dashboard con texto 'Cargando...'

Aquí están sucediendo algunas cosas:

  1. loading.tsx es un archivo especial de Next.js construido sobre React Suspense. Te permite crear una interfaz de usuario de respaldo para mostrar como reemplazo mientras se carga el contenido de la página.
  2. Como <SideNav> es estático, se muestra inmediatamente. El usuario puede interactuar con <SideNav> mientras se carga el contenido dinámico.
  3. El usuario no tiene que esperar a que la página termine de cargarse antes de navegar fuera de ella (esto se llama navegación interrumpible).

¡Felicidades! Acabas de implementar el streaming. Pero podemos hacer más para mejorar la experiencia del usuario. Mostremos un esqueleto de carga en lugar del texto Cargando….

Agregando esqueletos de carga

Un esqueleto de carga es una versión simplificada de la interfaz de usuario. Muchos sitios web los usan como marcador de posición (o respaldo) para indicar a los usuarios que el contenido se está cargando. Cualquier interfaz de usuario que agregues en loading.tsx se incrustará como parte del archivo estático y se enviará primero. Luego, el resto del contenido dinámico se transmitirá desde el servidor al cliente.

Dentro de tu archivo loading.tsx, importa un nuevo componente llamado <DashboardSkeleton>:

/app/dashboard/loading.tsx
import DashboardSkeleton from '@/app/ui/skeletons';
 
export default function Loading() {
  return <DashboardSkeleton />;
}

Luego, actualiza http://localhost:3000/dashboard, y ahora deberías ver:

Página del dashboard con esqueletos de carga

Solucionando el error del esqueleto de carga con grupos de rutas

En este momento, tu esqueleto de carga se aplicará a las facturas.

Como loading.tsx está un nivel más alto que /invoices/page.tsx y /customers/page.tsx en el sistema de archivos, también se aplica a esas páginas.

Podemos cambiar esto con Grupos de Rutas. Crea una nueva carpeta llamada /(overview) dentro de la carpeta dashboard. Luego, mueve tus archivos loading.tsx y page.tsx dentro de la carpeta:

Estructura de carpetas que muestra cómo crear un grupo de rutas usando paréntesis

Ahora, el archivo loading.tsx solo se aplicará a tu página de resumen del dashboard.

Los grupos de rutas te permiten organizar archivos en grupos lógicos sin afectar la estructura de la ruta URL. Cuando creas una nueva carpeta usando paréntesis (), el nombre no se incluirá en la ruta URL. Entonces /dashboard/(overview)/page.tsx se convierte en /dashboard.

Aquí, estás usando un grupo de rutas para asegurarte de que loading.tsx solo se aplique a tu página de resumen del dashboard. Sin embargo, también puedes usar grupos de rutas para separar tu aplicación en secciones (por ejemplo, rutas (marketing) y rutas (shop)) o por equipos para aplicaciones más grandes.

Transmitiendo un componente

Hasta ahora, estás transmitiendo una página completa. Pero también puedes ser más granular y transmitir componentes específicos usando React Suspense.

Suspense te permite diferir el renderizado de partes de tu aplicación hasta que se cumpla alguna condición (por ejemplo, se carguen los datos). Puedes envolver tus componentes dinámicos en Suspense. Luego, pasarle un componente de respaldo para mostrar mientras se carga el componente dinámico.

Si recuerdas la solicitud de datos lenta, fetchRevenue(), esta es la solicitud que está ralentizando toda la página. En lugar de bloquear toda tu página, puedes usar Suspense para transmitir solo este componente y mostrar inmediatamente el resto de la interfaz de usuario de la página.

Para hacerlo, necesitarás mover la obtención de datos al componente, actualicemos el código para ver cómo se vería:

Elimina todas las instancias de fetchRevenue() y sus datos de /dashboard/(overview)/page.tsx:

/app/dashboard/(overview)/page.tsx
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data'; // elimina fetchRevenue
 
export default async function Page() {
  const revenue = await fetchRevenue() // elimina esta línea
  const latestInvoices = await fetchLatestInvoices();
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    // ...
  );
}

Luego, importa <Suspense> desde React, y envuélvelo alrededor de <RevenueChart />. Puedes pasarle un componente de respaldo llamado <RevenueChartSkeleton>.

/app/dashboard/(overview)/page.tsx
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data';
import { Suspense } from 'react';
import { RevenueChartSkeleton } from '@/app/ui/skeletons';
 
export default async function Page() {
  const latestInvoices = await fetchLatestInvoices();
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Card title="Recaudado" value={totalPaidInvoices} type="collected" />
        <Card title="Pendiente" value={totalPendingInvoices} type="pending" />
        <Card title="Facturas totales" value={numberOfInvoices} type="invoices" />
        <Card
          title="Clientes totales"
          value={numberOfCustomers}
          type="customers"
        />
      </div>
      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
        <Suspense fallback={<RevenueChartSkeleton />}>
          <RevenueChart />
        </Suspense>
        <LatestInvoices latestInvoices={latestInvoices} />
      </div>
    </main>
  );
}

Finalmente, actualiza el componente <RevenueChart> para que obtenga sus propios datos y elimina la propiedad que se le pasa:

/app/ui/dashboard/revenue-chart.tsx
import { generateYAxis } from '@/app/lib/utils';
import { CalendarIcon } from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
import { fetchRevenue } from '@/app/lib/data';
 
// ...
 
export default async function RevenueChart() { // Haz el componente asíncrono, elimina las props
  const revenue = await fetchRevenue(); // Obtén los datos dentro del componente
 
  const chartHeight = 350;
  const { yAxisLabels, topLabel } = generateYAxis(revenue);
 
  if (!revenue || revenue.length === 0) {
    return <p className="mt-4 text-gray-400">No hay datos disponibles.</p>;
  }
 
  return (
    // ...
  );
}
 

Ahora actualiza la página, deberías ver la información del dashboard casi inmediatamente, mientras se muestra un esqueleto de respaldo para <RevenueChart>:

Página del dashboard con esqueleto del gráfico de ingresos y componentes Card y Latest Invoices cargados

Práctica: Transmitiendo <LatestInvoices>

¡Ahora es tu turno! Practica lo que acabas de aprender transmitiendo el componente <LatestInvoices>.

Mueve fetchLatestInvoices() desde la página al componente <LatestInvoices>. Envuelve el componente en un límite <Suspense> con un respaldo llamado <LatestInvoicesSkeleton>.

Una vez que estés listo, expande el toggle para ver el código de solución:

Agrupando componentes

¡Genial! Ya casi estás ahí, ahora necesitas envolver los componentes <Card> en Suspense. Podrías obtener datos para cada tarjeta individual, pero esto podría provocar un efecto de aparición a medida que las tarjetas se cargan, lo que puede ser visualmente molesto para el usuario.

Entonces, ¿cómo abordarías este problema?

Para crear más un efecto escalonado, puedes agrupar las tarjetas usando un componente envoltorio. Esto significa que el <SideNav/> estático se mostrará primero, seguido por las tarjetas, etc.

En tu archivo page.tsx:

  1. Elimina tus componentes <Card>.
  2. Elimina la función fetchCardData().
  3. Importa un nuevo componente envoltorio llamado <CardWrapper />.
  4. Importa un nuevo componente esqueleto llamado <CardsSkeleton />.
  5. Envuelve <CardWrapper /> en Suspense.
/app/dashboard/(overview)/page.tsx
import CardWrapper from '@/app/ui/dashboard/cards';
// ...
import {
  RevenueChartSkeleton,
  LatestInvoicesSkeleton,
  CardsSkeleton,
} from '@/app/ui/skeletons';
 
export default async function Page() {
  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Suspense fallback={<CardsSkeleton />}>
          <CardWrapper />
        </Suspense>
      </div>
      // ...
    </main>
  );
}

Luego, ve al archivo /app/ui/dashboard/cards.tsx, importa la función fetchCardData(), e invócala dentro del componente <CardWrapper/>. Asegúrate de descomentar cualquier código necesario en este componente.

/app/ui/dashboard/cards.tsx
// ...
import { fetchCardData } from '@/app/lib/data';
 
// ...
 
export default async function CardWrapper() {
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    <>
      <Card title="Recaudado" value={totalPaidInvoices} type="collected" />
      <Card title="Pendiente" value={totalPendingInvoices} type="pending" />
      <Card title="Facturas totales" value={numberOfInvoices} type="invoices" />
      <Card
        title="Clientes totales"
        value={numberOfCustomers}
        type="customers"
      />
    </>
  );
}

Actualiza la página, y deberías ver todas las tarjetas cargarse al mismo tiempo. Puedes usar este patrón cuando quieras que varios componentes se carguen al mismo tiempo.

Decidiendo dónde colocar tus límites de Suspense

Dónde coloques tus límites de Suspense dependerá de algunas cosas:

  1. Cómo quieres que el usuario experimente la página mientras se transmite.
  2. Qué contenido quieres priorizar.
  3. Si los componentes dependen de la obtención de datos.

Echa un vistazo a tu página del dashboard, ¿hay algo que hubieras hecho de manera diferente?

No te preocupes. No hay una respuesta correcta.

  • Podrías transmitir toda la página como lo hicimos con loading.tsx... pero eso puede llevar a un tiempo de carga más largo si uno de los componentes tiene una obtención de datos lenta.
  • Podrías transmitir cada componente individualmente... pero eso puede provocar que la interfaz de usuario aparezca en la pantalla a medida que esté lista.
  • También podrías crear un efecto escalonado transmitiendo secciones de la página. Pero necesitarás crear componentes envoltorios.

Dónde coloques tus límites de Suspense variará según tu aplicación. En general, es una buena práctica mover tus obtenciones de datos a los componentes que las necesitan, y luego envolver esos componentes en Suspense. Pero no hay nada malo en transmitir las secciones o toda la página si eso es lo que necesita tu aplicación.

No tengas miedo de experimentar con Suspense y ver qué funciona mejor, es una API poderosa que puede ayudarte a crear experiencias de usuario más agradables.

Mirando hacia adelante

El streaming y los Componentes del Servidor nos dan nuevas formas de manejar la obtención de datos y los estados de carga, con el objetivo final de mejorar la experiencia del usuario final.

En el próximo capítulo, aprenderás sobre el Prerrenderizado Parcial, un nuevo modelo de renderizado de Next.js construido con el streaming en mente.