Inicio / Artículos

Cargando, esperando, fallando: cómo encajan los límites de pereza, suspenso y error

Comprenda cómo React.lazy, Suspense y los límites de error dividen el código, muestran soluciones alternativas y evitan caídas, así como dónde colocar cada uno de ellos para lograr una mejor experiencia de usuario.

Editor: REACTAPP.TOP9 min de lectura1968 palabras

Fuentes: React Suspense, Lazy Loading & Error Boundaries — A Practical Guide

Una pantalla de React puede encontrarse en tres estados problemáticos: su código no se ha descargado, su contenido aún no está listo o la renderización ha fallado. React.lazy pospone la carga del código, Suspense muestra un elemento de reemplazo mientras algo está en proceso, y un Error Boundary sustituye un subárbol que ha fallado por una alternativa. Una vez que entiendas cómo trabajan juntos, podrás dividir los paquetes de forma sensata y colocar estos límites para que el resto de la página siga siendo utilizable.

Posponer la carga del código con React.lazy

El cargado diferido significa que el código de un componente se carga solo cuando React necesita renderizarlo por primera vez, y no como parte del paquete inicial. La diferencia se observa en la importación: una import Dashboard from "./Dashboard" estática carga el módulo de inmediato, mientras que envolver una importación dinámica en lazy() permite al compilador emitir un chunk separado. (En el fragmento, la segunda importación se muestra únicamente a efectos de comparación.)

import { lazy } from "react";

const Dashboard = lazy(() => import("./Dashboard"));

Instead of:

import Dashboard from "./Dashboard";

Qué te aporta el cargado diferido

Imagina una aplicación con varias áreas distintas:

Application
├── Home
├── Products
├── Dashboard
├── Analytics
├── Admin
└── Payment

Quien accede a la página principal aún no necesita estas áreas:

Analytics
Admin
Payment

Enviarlas de todos modos aumenta el tamaño del descarga inicial. Con el cargado diferido, los demás chunks se envían según sea necesario:

Initial Load
     ↓
Home + common code
     ↓
User opens Dashboard
     ↓
Download Dashboard chunk
     ↓
Render Dashboard

Cómo funciona el cargado diferido en el fondo

lazy() acepta una función que devuelve una promesa, casi siempre un import() dinámico. El módulo de destino debe tener una exportación por defecto que sea un componente. La secuencia es la siguiente (el diagrama es conceptual, no código):

const Dashboard = lazy(() => import("./Dashboard"));

Conceptually:

lazy()
  ↓
dynamic import()
  ↓
JavaScript chunk requested
  ↓
Promise pending
  ↓
Component becomes available
  ↓
React renders component

Existen dos reglas prácticas. Declare los componentes lazy en el nivel superior del módulo, nunca dentro de otro componente, ya que de lo contrario cada renderizado creará un nuevo tipo de componente y restablecerá su estado. Y como el chunk llega de forma asíncrona, algo debe decidir qué verá el usuario mientras tanto. Esa es la función de Suspense.

Mostrar un marcador de posición con Suspense

Suspense renderiza un elemento de respaldo mientras cualquier componente descendiente esté en estado suspendido, es decir, no pueda renderizarse aún. Envuelva el componente lazy y pase el elemento de respaldo:

import { lazy, Suspense } from "react";

const Dashboard = lazy(() => import("./Dashboard"));

function App() {
  return (
    <Suspense fallback={<p>Loading dashboard...</p>}>
      <Dashboard />
    </Suspense>
  );
}

Mientras se descarga el fragmento, React se dirige al límite más cercano y muestra su contenido alternativo:

Dashboard
    ↓
Not ready
    ↓
Suspense
    ↓
"Loading dashboard..."

Cuando la promesa se resuelve, React intenta renderizar nuevamente y el contenido real reemplaza al alternativo:

Dashboard loaded
      ↓
React retries rendering
      ↓
Dashboard displayed

Suspense no se encarga de la carga; lazy() (o una biblioteca de datos con soporte para Suspense) es la que inicia el proceso. Suspense solo indica dónde puede aparecer un estado de espera y cómo se verá.

Uso conjunto de lazy y Suspense

La mayoría de los códigos reales combinan ambos, generalmente con un contenido alternativo más elaborado que una simple línea de texto:

const Dashboard = lazy(() => import("./Dashboard"));

function App() {
  return (
    <Suspense fallback={<DashboardSkeleton />}>
      <Dashboard />
    </Suspense>
  );
}

El ciclo de vida completo de la primera visita, nuevamente en forma de diagrama:

User opens Dashboard
        ↓
React tries to render Dashboard
        ↓
Dashboard chunk isn't available
        ↓
Dynamic import starts
        ↓
Suspense fallback shown
        ↓
Dashboard chunk loads
        ↓
React retries rendering
        ↓
Dashboard displayed

Estructuras básicas en lugar de indicadores de carga

Un contenido alternativo de texto simple funciona, pero no informa al usuario sobre lo que está por llegar:

<Suspense fallback={<p>Loading...</p>}>

Un componente esqueleto que imita el diseño final suele ser una mejor opción:

<Suspense fallback={<DashboardSkeleton />}>

Reserva aproximadamente el espacio adecuado, reduciendo los desplazamientos en el diseño y haciendo que la espera parezca más corta:

┌─────────────────────────────┐
│ Dashboard                   │
├─────────────────────────────┤
│ ░░░░░░░   ░░░░░░░           │
│ ░░░░░░░   ░░░░░░░           │
│                             │
│ ░░░░░░░░░░░░░░░░░░          │
└─────────────────────────────┘

Anidamiento de límites de Suspense

Rara vez se desea tener un único límite para toda la aplicación. Los límites pueden anidarse, y cada componente en estado de suspensión es gestionado por el más cercano por encima de él:

<Suspense fallback={<PageSkeleton />}>
  <Header />

  <Suspense fallback={<ProductsSkeleton />}>
    <Products />
  </Suspense>

  <Suspense fallback={<RecommendationsSkeleton />}>
    <Recommendations />
  </Suspense>
</Suspense>

En lugar de que toda la página se convierta en un único mensaje de carga,

Entire page → Loading...

cada sección puede mostrarse de forma independiente:

Header                  ✅

Products                Loading...
[Product Skeleton]

Recommendations         Loading...
[Recommendation Skeleton]

Manejo de errores con límites de Error

La espera es un resultado; el fallo es otro. Supongamos que un componente lanza un error durante la renderización:

function Dashboard() {
  throw new Error("Dashboard crashed!");
}

Un error de renderizado no capturado desmonta toda la raíz de React, dejando una página en blanco. Un Error Boundary es un componente que intercepta los errores lanzados por sus descendientes y muestra una interfaz de respaldo en su lugar:

Dashboard
    ↓
Rendering Error
    ↓
Error Boundary
    ↓
Fallback UI

Usarlo es similar a envolver cualquier otro componente, y el usuario ve un mensaje de “Algo salió mal” en lugar de una pantalla vacía:

<ErrorBoundary>
  <Dashboard />
</ErrorBoundary>

Escribir un Error Boundary

Los Error Boundaries siguen siendo componentes de clase; no existe un equivalente con Hooks en React. Hay dos métodos importantes. La función estática getDerivedStateFromError se ejecuta durante el renderizado y devuelve un estado que permite pasar a la interfaz de respaldo. componentDidCatch se ejecuta posteriormente con el error y info.componentStack, que sirve para registrar información o enviar informes.

import React from "react";

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      hasError: false,
    };
  }

  static getDerivedStateFromError() {
    return {
      hasError: true,
    };
  }

  componentDidCatch(error, info) {
    console.error(error);
    console.error(info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return <h2>Something went wrong.</h2>;
    }

    return this.props.children;
  }
}

Se utiliza exactamente como en el ejemplo anterior:

<ErrorBoundary>
  <Dashboard />
</ErrorBoundary>

Esta versión mínima no cuenta con un límite de errores; es necesario que el botón “Intentar de nuevo” establezca hasError en false nuevamente.

Cómo React encuentra el límite de errores

Se coloca un límite alrededor de un componente de perfil,

<ErrorBoundary>
  <Profile />
</ErrorBoundary>

donde el componente lanza un error:

function Profile() {
  throw new Error("Profile failed");
}

React renderiza los componentes hacia abajo en el árbol hasta que ocurre el error:

ErrorBoundary
      ↓
Profile
      ↓
💥 Error

Luego busca hacia arriba el límite de errores más cercano:

Profile
   ↑
ErrorBoundary

Ese límite pasa a su estado de error,

{
  hasError: true
}

y muestra el contenido alternativo. Un límite de errores no puede capturar sus propios errores, solo los de sus hijos; un error dentro del contenido alternativo se propaga al siguiente límite de errores más arriba.

Errores que captura un límite de errores

Los límites de errores manejan los errores que se generan mientras React está renderizando, en los métodos del ciclo de vida y en los constructores de componentes descendientes. Un caso clásico es leer una propiedad de undefined durante el renderizado:

function Profile() {
  const user = undefined;

  return <h1>{user.name}</h1>;
}

También capturan las importaciones perezosas que fallan. Si un chunk no se descarga, por ejemplo después de que una actualización elimine el archivo antiguo, el error aparece en el límite de errores más cercano, y no dentro de Suspense.

Errores que pasan desapercibidos por los límites

Pregunta frecuente en entrevistas: los límites no detectan errores en los manejadores de eventos, ya que estos se ejecutan fuera del proceso de renderizado.

function Button() {
  const handleClick = () => {
    throw new Error("Click failed");
  };

  return (
    <button onClick={handleClick}>
      Click
    </button>
  );
}

Maneje estos casos donde ocurren, mediante el manejo habitual de errores en los manejadores correspondientes:

try {
  // operation
} catch (error) {
  // handle error
}

Lo mismo aplica a las devoluciones asíncronas como los temporizadores o cadenas de promesas que generan errores después de que finalice el renderizado:

setTimeout(() => {
  throw new Error("Async error");
}, 1000);

Si se produce un error que debería mostrar la solución de respaldo del límite, atrápelo y guárdalo en el estado, para luego lanzarlo durante la siguiente renderización.

Combinando los tres elementos

Un patrón de producción envuelve a Suspense dentro de un Error Boundary:

<ErrorBoundary>
  <Suspense fallback={<DashboardSkeleton />}>
    <Dashboard />
  </Suspense>
</ErrorBoundary>

Con el panel de control definido de forma perezosa,

const Dashboard = lazy(() => import("./Dashboard"));

el flujo combinado abarca todos los resultados. La primera rama se interpreta como “¿falta código?”: si es así, React inicia la importación y muestra el esqueleto; posteriormente, la renderización tiene éxito o llega al Error Boundary.

                    Dashboard
                        │
                        ▼
                  Need the code?
                        │
               ┌────────┴────────┐
               │                 │
              YES                NO
               │                 │
          Render normally    dynamic import()
                                 │
                              Pending
                                 │
                                 ▼
                             Suspense
                                 │
                                 ▼
                            Skeleton UI
                                 │
                              Loaded
                                 │
                                 ▼
                           Render again
                                 │
                         ┌───────┴───────┐
                         │               │
                      Success          Error
                         │               │
                         ▼               ▼
                     Dashboard     Error Boundary
                                         │
                                         ▼
                                      Error UI

División por ruta

Las rutas son los puntos de división más naturales, ya que cada página es una pantalla autónoma. Declare cada página de forma perezosa:

const Home = lazy(() => import("./pages/Home"));
const Products = lazy(() => import("./pages/Products"));
const Dashboard = lazy(() => import("./pages/Dashboard"));

luego envuelva al router en un límite para que la navegación a una página no cargada muestre un cargador:

<Suspense fallback={<PageLoader />}>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/products" element={<Products />} />
    <Route path="/dashboard" element={<Dashboard />} />
  </Routes>
</Suspense>

Saber qué no dividir

Cargar de forma diferida un componente pequeño es contraproducente:

const Button = lazy(() => import("./Button"));

Cada componente cargado de forma diferida genera una solicitud y un estado de carga, algo que un botón nunca compensa. Los casos adecuados son paneles grandes, áreas de análisis y administración, editores de texto enriquecido, mapas, gráficos complejos o bibliotecas de terceros, flujos de pago y rutas poco visitadas. Divida el código que sea costoso o poco utilizado, no todo.

Colocación intencionada de los límites

La ubicación del elemento de suspensión determina qué desaparece

Un límite situado arriba en la estructura significa que cualquier componente lento reemplazará una gran área con su alternativa:

<Suspense fallback={<FullPageLoader />}>
  <EntireApplication />
</Suspense>

Al colocarlo alrededor de la parte lenta, el encabezado y el pie de página permanecen visibles y utilizables durante la carga:

<Header />

<Suspense fallback={<DashboardSkeleton />}>
  <Dashboard />
</Suspense>

<Footer />

La ubicación del límite de errores determina qué sobrevive

La misma lógica se aplica a los fallos. Un único límite en el nivel raíz convierte cualquier error en una pantalla de error de página completa:

<ErrorBoundary>
  <EntireApplication />
</ErrorBoundary>

Al encapsular secciones independientes, un fallo se mantiene localizado:

<App>
  <Header />

  <ErrorBoundary>
    <Dashboard />
  </ErrorBoundary>

  <ErrorBoundary>
    <Recommendations />
  </ErrorBoundary>

  <Footer />
</App>

Si las recomendaciones fallan, todo lo demás sigue funcionando:

Header              ✅
Dashboard           ✅
Recommendations     ❌
Footer              ✅

Un límite en el nivel raíz sigue siendo útil como último recurso alrededor de los límites más específicos.

El modelo mental en una imagen

             Lazy Loading
                  │
                  ▼
             Code needed?
                  │
                  ▼
             Suspense
                  │
           ┌──────┴──────┐
           │             │
        Loading         Ready
           │             │
        Fallback         ▼
                       Render
                         │
                   ┌─────┴─────┐
                   │           │
                Success       Error
                   │           │
                   ▼           ▼
                   UI    Error Boundary
                              │
                              ▼
                           Error UI

Puntos clave

  • React.lazy con import() dinámico divide el código en fragmentos que se cargan durante la primera renderización; el módulo necesita una exportación por defecto.
  • Suspense no carga nada; define dónde aparecerá un fallback mientras los elementos descendientes están en estado suspendido.
  • Los límites de errores capturan errores de renderizado, del ciclo de vida y del constructor, incluidas las importaciones lazy fallidas, pero no los errores de manejadores de eventos ni los errores asíncronos posteriores.
  • Suspense no maneja errores, por lo que debe usarse junto con un Error Boundary.
  • Divida la estructura a nivel de ruta y alrededor de funcionalidades complejas que se usan con poca frecuencia, no de componentes pequeños.
  • La ubicación del Error Boundary es una decisión de experiencia de usuario: colóquelos ambos alrededor de secciones significativas e independientes.