wandres.dev
ERRORBOUNDARY A FONDO · errores en async

Suspense y ErrorBoundary: el orden que separa carga de fallo

Suspense y ErrorBoundary son ejes ortogonales: uno gobierna el todavia no, el otro el salio mal. El orden canonico coloca el ErrorBoundary por fuera del Suspense para que un fallo de carga sustituya toda la region en lugar de quedar atrapado bajo el indicador de progreso. Por que el throw de la lectura atraviesa Suspense sin ser interceptado, cuando conviene invertir el anidamiento para preservar el esqueleto de la interfaz, por que el lector debe vivir dentro del Suspense para que coordine, y por que un refetch no vuelve a suspender por defecto sino que sirve el valor previo con latest hasta que llega el nuevo o falla.

⏱ 16 min

Un recurso asíncrono tiene tres estados que la interfaz debe pintar: cargando, listo y roto. Suspense sabe pintar el primero; ErrorBoundary, el tercero. No compiten: son ejes ortogonales, y la pregunta de diseño no es cuál usar sino en qué orden anidarlos. La respuesta canónica —el límite por fuera, la suspensión por dentro— no es arbitraria: se deduce de cómo viaja el throw de una lectura errónea a través del árbol de owners.

🎯 Al terminar esta lección sabrás
  • Separar los dos ejes: Suspense gobierna pending, ErrorBoundary gobierna errored.
  • Justificar el orden canónico con ErrorBoundary por fuera de Suspense.
  • Entender cuándo invertir el anidamiento para conservar el esqueleto de la página.
  • Predecir por qué un refetch no vuelve a mostrar el fallback de carga por defecto.

Dos ejes ortogonales

Suspense intercepta la señal de que hay recursos pendientes leídos en su interior y, mientras dure esa pendencia, muestra su fallback de carga. ErrorBoundary intercepta los throw síncronos de sus descendientes y muestra su fallback de error. Son mecanismos distintos sobre estados distintos: uno se registra en un contexto de suspensión, el otro captura excepciones; uno espera, el otro contiene. Un mismo recurso puede pasar por ambos —primero pendiente, luego roto— y cada componente reacciona a su fase sin saber del otro.

import { Suspense, ErrorBoundary, createResource } from "solid-js";

function Panel(props: { id: string }) {
  const [datos] = createResource(() => props.id, cargar);
  return (
    <ErrorBoundary fallback={(err, reset) => <Fallo err={err} onReset={reset} />}>
      <Suspense fallback={<Esqueleto />}>
        <Vista datos={datos()} /> {/* pendiente suspende, error lanza */}
      </Suspense>
    </ErrorBoundary>
  );
}

La correspondencia con la máquina de estados del recurso es exacta: pending activa el fallback de Suspense; ready y refreshing dejan pasar el contenido; errored, al leerse, dispara el throw que busca un ErrorBoundary. Cada componente atiende su fase y ninguno pisa al otro. Esa independencia es justo lo que te deja componerlos en cualquier orden y obtener políticas distintas de degradación.

Conviene fijar el vocabulario antes de anidarlos. Pendiente significa que la petición sigue en vuelo y aún no hay valor; roto, que terminó lanzando; listo y refrescando, que hay un valor que pintar, quizá mientras otro llega. Suspense solo distingue pendiente del resto; ErrorBoundary, solo roto del resto. Ninguno conoce los estados del otro, y esa ignorancia mutua es la que hace su composición libre y predecible.

El orden canónico: límite por fuera

La clave está en recordar que un recurso en estado errored, al leerse, lanza. Ese throw sube por el árbol de owners buscando un ErrorBoundary. Suspense no lo intercepta: solo reacciona a la pendencia, no a las excepciones, así que el throw lo atraviesa limpiamente. Por eso el límite debe estar por fuera: es el único que puede atraparlo cuando burbujea hacia arriba.

flowchart TD
START[render de la region] --> Q{estado del recurso}
Q -->|pending| SUS[Suspense muestra su fallback de carga]
Q -->|ready| CONT[contenido pintado]
Q -->|errored| THROW[la lectura lanza]
THROW --> PASS[atraviesa Suspense sin ser interceptado]
PASS --> EB[ErrorBoundary exterior muestra el fallback de error]
style SUS fill:#89b4fa,color:#11111b
style EB fill:#f38ba8,color:#11111b
style CONT fill:#a6e3a1,color:#11111b

Con este orden, un fallo de carga sustituye toda la región —incluido el hueco donde vivía el esqueleto— por el mensaje de error. Es el comportamiento que quieres para la mayoría de los casos: si la sección no pudo cargar, no tiene sentido seguir mostrando su andamiaje de carga; se reemplaza entera por el estado de fallo, con su reset para reintentar. El fallback del Suspense y el del ErrorBoundary son excluyentes en el tiempo: nunca coexisten, porque el recurso está pendiente o roto, jamás las dos cosas.

Cuándo invertir el anidamiento

El orden inverso —Suspense por fuera, ErrorBoundary por dentro— también es legítimo, y la diferencia es qué se reemplaza al fallar. Con el límite por dentro, solo el contenido que él envuelve se sustituye por el error; cualquier estructura hermana que viva dentro del Suspense pero fuera del límite permanece. Sirve cuando quieres conservar un esqueleto de página —cabecera, columnas, layout— y que el error solo ocupe la ranura del dato que falló.

<Suspense fallback={<PaginaEsqueleto />}>
  <Cabecera />
  <ErrorBoundary fallback={<RanuraCaida />}>
    <Contenido datos={datos()} /> {/* solo esta ranura se vuelve error */}
  </ErrorBoundary>
</Suspense>

Hay una condición que ninguno de los dos órdenes perdona: el lector del recurso debe vivir dentro del Suspense. Si lees data() por encima de la suspensión, no habrá quien coordine la pendencia, y verás undefined antes de tiempo o el error aflorará sin un esqueleto que lo anteceda. La lectura y su Suspense deben compartir subárbol; el ErrorBoundary puede quedar por fuera, pero el Suspense tiene que envolver al que lee.

En interfaces con varias secciones independientes, la combinación más útil mezcla ambos órdenes: un Suspense compartido que coordina la carga global y un ErrorBoundary por widget dentro de él, de modo que el fallo de uno no colapse el resto ni borre el esqueleto compartido.

<Suspense fallback={<Esqueleto />}>
  <ErrorBoundary fallback={<WidgetCaido id="a" />}><WidgetA /></ErrorBoundary>
  <ErrorBoundary fallback={<WidgetCaido id="b" />}><WidgetB /></ErrorBoundary>
</Suspense>;

El resultado es una carga coordinada con fallos aislados: un único esqueleto mientras ambos widgets se resuelven, pero si uno rompe, solo su celda muestra el error y el otro sigue vivo. Cuando cada sección debe además esperar por su cuenta, anidas los dos por widget y los generas en bucle:

<For each={secciones()}>
  {(s) => (
    <ErrorBoundary fallback={<Caido id={s.id} />}>
      <Suspense fallback={<Esqueleto id={s.id} />}>
        <Widget fuente={s} />
      </Suspense>
    </ErrorBoundary>
  )}
</For>;

La granularidad del ErrorBoundary decide el radio del fallo; la del Suspense, el grano de la espera. Ajustar ambas por separado es lo que convierte un cuadro de mando frágil en uno que se degrada celda a celda.

Un refetch no vuelve a suspender

El matiz que más sorprende: cuando revalidas un recurso ya resuelto con refetch, Suspense no vuelve a mostrar su fallback de carga. El recurso pasa a estado refreshing y resource.loading vale verdadero, pero Suspense mantiene en pantalla el contenido previo sirviendo resource.latest, para evitar el parpadeo de volver al esqueleto en cada refresco. La suspensión visible es un evento de la primera carga, no de las siguientes.

La consecuencia para los errores es directa: si un refetch falla, no verás un destello de carga antes del error; la lectura del recurso recién roto lanza y el ErrorBoundary toma el relevo sobre el contenido que hasta ese momento seguía visible. Si quisieras coordinar una transición explícita —difuminar el contenido viejo mientras llega el nuevo— envolverías el disparo en startTransition, que mantiene lo anterior montado y aplica el cambio cuando el nuevo estado está listo. Pero el comportamiento por defecto ya es el correcto para casi todo: cargar con esqueleto una vez, refrescar sin parpadeo siempre.

El control explícito de esa transición vive en useTransition, que expone una bandera de pendencia y una función para envolver el cambio. Mientras la transición corre, el contenido anterior sigue montado y la interfaz puede señalar el trabajo en curso —atenuar, deshabilitar el botón— sin retroceder al esqueleto.

import { useTransition } from "solid-js";

function BotonActualizar(props: { refetch: () => void }) {
  const [pendiente, empezar] = useTransition();
  return (
    <button disabled={pendiente()} onClick={() => empezar(() => props.refetch())}>
      {pendiente() ? "Actualizando..." : "Actualizar"}
    </button>
  );
}

Y si esa revalidación en transición falla, la secuencia es la esperada: la lectura del recurso recién roto lanza, el ErrorBoundary toma el relevo, y el usuario pasa del contenido viejo al estado de error sin un parpadeo de carga intermedio.

🛡️

Límite por fuera

Un fallo sustituye toda la region, esqueleto incluido. El caso comun: la seccion entera se rinde y ofrece reset.

🧩

Límite por dentro

Solo la ranura fallida se vuelve error y el marco de la pagina sobrevive. Para degradar sin perder contexto.

ℹ️
Un solo Suspense puede coordinar muchos recursos

Todos los recursos leídos dentro de un mismo Suspense comparten su indicador: el fallback se muestra mientras cualquiera siga pendiente y desaparece cuando todos resuelven. Es la forma de evitar una cascada de spinners cuando una vista depende de varias peticiones: un único esqueleto hasta que la vista entera esté lista. Para orquestar el orden de revelado entre varios bloques existe SuspenseList, todavía experimental.

⚠️
Leer el recurso fuera del Suspense rompe la coordinación

Si colocas la lectura de data() por encima del Suspense en lugar de dentro, no habrá nada que coordinar: no verás su fallback de carga y el undefined inicial —o el throw del error— aflorará sin la fase de espera. El Suspense solo gobierna las lecturas de su interior. Asegúrate de que el componente que lee el recurso sea descendiente del Suspense, no su hermano ni su ancestro.

El anidamiento codifica una política de degradación, no un detalle de sintaxis

Colocar ErrorBoundary y Suspense no es rellenar una plantilla: es declarar, en la topología del árbol, cómo se degrada tu interfaz ante cada una de las dos formas en que una carga puede no darte lo que pintar. La ortogonalidad es total porque atacan estados disjuntos —pendiente y roto— con mecanismos disjuntos —registro en un contexto de suspensión frente a captura de excepciones—, y esa independencia es justo lo que te deja componerlos en cualquier orden y obtener políticas distintas. El límite por fuera dice “si esto falla, toda la región deja de existir y se convierte en un estado de error recuperable”; el límite por dentro dice “si esto falla, mantengo el marco vivo y solo esta ranura se rinde”. La decisión no es estética: determina qué sigue en pie cuando algo se rompe, y por tanto cuánto contexto conserva el usuario para entender qué pasó y qué puede hacer. A esto se suma el eje temporal que aporta Suspense: la suspensión visible pertenece a la primera aparición, mientras que las revalidaciones fluyen bajo el contenido usando latest, de modo que la interfaz nunca retrocede a un esqueleto una vez que ya mostró datos. Cuando ves estos dos componentes como los dos ejes de un plano —qué esperar y qué contener— y el anidamiento como la elección del origen de coordenadas, dejas de copiar el patrón de memoria y empiezas a diseñar la degradación de cada región según lo que esa región significa para quien la mira.

⚔️ Diseña la degradación
  1. Monta ErrorBoundary por fuera de Suspense y provoca un fallo de carga; confirma que el esqueleto desaparece y toda la región se vuelve el fallback de error.
  2. Invierte el anidamiento con una Cabecera hermana dentro del Suspense; verifica que la cabecera sobrevive al error y solo la ranura interior se rinde.
  3. Lee dos recursos bajo un mismo Suspense y comprueba que el esqueleto se mantiene hasta que ambos resuelven.
  4. Resuelve el recurso, dispara un refetch que falle y observa que no hay destello de carga: el error aparece directamente sobre el contenido previo.
  5. Envuelve el refetch en startTransition y contrasta cómo cambia la experiencia durante la revalidación.