Saltar al contenido

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.

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.

TSX
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.

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ó.

TSX
<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.

TSX
<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:

TSX
<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.

TSX
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.