Suspense: un fallback mientras los recursos cargan
Suspense es el límite asíncrono de Solid: envuelve un subárbol, muestra un fallback mientras cualquier recurso leído bajo él siga pendiente y revela el contenido cuando todos resuelven. La clave de su diseño es que la detección ocurre en la lectura del recurso, no en su creación: un createResource que nadie lee no suspende. Por dentro no es un condicional sino un contador de lecturas pendientes que un SuspenseContext lleva, y sus hijos no se destruyen mientras carga: se ejecutan y se retienen fuera de pantalla con su estado intacto. Gobierna el todavía no, ortogonal al ErrorBoundary que gobierna el salió mal.
Un recurso asíncrono tiene tres estados —cargando, listo, fallido— y pintarlos a mano con condicionales es tedioso y frágil. Suspense invierte el problema: en vez de preguntar «¿ya cargó?» en cada punto, envuelves una región con un límite que muestra un fallback mientras cualquier recurso leído dentro siga pendiente, y revela el contenido real cuando todos resuelven. No le declaras qué esperar; lo descubre observando qué recursos se leen bajo él. Esta lección desmonta ese mecanismo: cómo detecta la espera, qué la activa y qué les pasa a los hijos mientras tanto.
- Declarar un
Suspensecon sufallbackalrededor de un subárbol que lee recursos. - Entender que la detección ocurre en la lectura del recurso, no en su creación.
- Ver que los hijos se ejecutan y se retienen fuera de pantalla en lugar de destruirse.
- Distinguir el «todavía no» de
Suspensedel «salió mal» deErrorBoundary.
El límite asíncrono y su fallback
Suspense es un componente que recibe dos cosas: un fallback —lo que se muestra mientras se espera— y sus children —el contenido real—. Colócalo alrededor de cualquier subárbol que lea uno o más recursos y Solid pintará el fallback hasta que esos recursos estén resueltos, momento en el que lo sustituye por los hijos.
import { createResource, Suspense } from "solid-js";
function Perfil(props: { id: string }) {
const [usuario] = createResource(() => props.id, cargarUsuario);
return (
<Suspense fallback={<p>Cargando perfil…</p>}>
<h1>{usuario()?.nombre}</h1>
<p>{usuario()?.bio}</p>
</Suspense>
);
}
Nada aquí dice explícitamente «espera a usuario». Suspense no conoce ese recurso: se entera de que hay algo pendiente porque, al renderizar los hijos, la lectura de usuario() mientras aún carga se lo comunica. Esa es la diferencia esencial con un Show, donde tú evalúas la condición a mano; con Suspense la condición se autodescubre a partir de las lecturas que ocurren dentro. Añadir un segundo recurso al subárbol no cambia nada del código del límite: se suma solo.
La detección ocurre en la lectura
Por dentro, Suspense no es un condicional: es un contador de lecturas pendientes. El componente provee un SuspenseContext con un contador. Cada vez que se lee un recurso que todavía está cargando bajo ese límite, el recurso incrementa el contador; cuando resuelve, lo decrementa. Mientras el contador sea mayor que cero, se pinta el fallback; cuando vuelve a cero, se revelan los hijos.
flowchart TD R[lee un recurso pendiente] --> INC[incrementa el contador del SuspenseContext] INC --> N[contador mayor que cero] N --> FB[Suspense pinta el fallback] OK[el recurso resuelve] --> DEC[decrementa el contador] DEC --> Z[contador vuelve a cero] Z --> REV[Suspense revela los hijos] style FB fill:#f9e2af,color:#11111b style REV fill:#a6e3a1,color:#11111b
La consecuencia práctica es contraintuitiva y conviene grabarla: crear un recurso no suspende; leerlo mientras carga, sí. Un createResource cuyo valor nadie lee en el árbol bajo el límite jamás activará el fallback, por muy lento que sea su fetch. Y a la inversa, un recurso leído en dos sitios distintos bajo el mismo límite cuenta una sola espera coordinada, no dos. El límite reacciona a lo que se observa, no a lo que existe.
// No suspende: el recurso se crea pero nadie lo lee bajo el Suspense.
const [datos] = createResource(cargar);
<Suspense fallback={<Spinner />}>
<p>Contenido estático</p>
</Suspense>;
// Sí suspende: la lectura de datos() ocurre dentro del árbol del límite.
<Suspense fallback={<Spinner />}>
<p>{datos()?.titulo}</p>
</Suspense>;
El estado que mueve el contador es el mismo que expone resource.state, más rico que el booleano resource.loading:
resource.state |
Significado | loading |
|---|---|---|
unresolved |
sin origen aún, no ha empezado | no |
pending |
primera carga en curso | sí |
ready |
resuelto con valor | no |
refreshing |
revalidando con el valor previo | sí |
errored |
la promesa se rechazó | no |
Solo pending y refreshing —los dos estados en que loading es cierto— incrementan el contador del límite; leer un recurso en ready no suspende. Esta tabla es el diccionario entre lo que le pasa al recurso y lo que hace el Suspense.
Los hijos se ejecutan y se retienen
Un malentendido común es creer que Suspense no monta a sus hijos hasta que los datos llegan. Es al revés, y por una razón de diseño: para saber qué recursos esperar, el límite tiene que ejecutar a los hijos —crear sus computaciones y correr su cuerpo—, porque solo así se producen las lecturas que revelan las esperas. Suspense no aplaza la ejecución, sino que retiene la salida: los hijos corren, su DOM se construye fuera de pantalla, y el fallback ocupa su lugar hasta que todo está listo.
Esto contrasta de raíz con Show, que destruye y recrea su subárbol al cambiar la condición. Suspense mantiene una sola instancia de los hijos y solo alterna qué se muestra, así que el estado se preserva: si un recurso revalida y el límite vuelve a esperar, los signals y el DOM ya construidos siguen vivos y no se remonta desde cero.
// Show DESTRUYE y recrea el subárbol cuando la condición cambia.
<Show when={!datos.loading} fallback={<Spinner />}>
<Detalle datos={datos()} />
</Show>;
// Suspense RETIENE una sola instancia y solo alterna qué se ve.
<Suspense fallback={<Spinner />}>
<Detalle datos={datos()} />
</Suspense>;
El límite espera; el ErrorBoundary contiene
Suspense gobierna el «todavía no»: la ausencia temporal de un valor. No tiene nada que decir sobre el «salió mal»: si el fetch de un recurso se rechaza, esa rama no es competencia del límite de espera sino de un ErrorBoundary. Son ejes ortogonales y se combinan colocando el ErrorBoundary por fuera, de modo que un fallo sustituya toda la región en lugar de quedar atrapado bajo el indicador de carga.
import { createResource, Suspense, ErrorBoundary } from "solid-js";
<ErrorBoundary fallback={(err) => <p>Falló: {err.message}</p>}>
<Suspense fallback={<Spinner />}>
<Perfil id={id()} />
</Suspense>
</ErrorBoundary>;
El reparto es limpio: mientras el recurso carga manda Suspense y se ve el fallback; si resuelve, se ven los hijos; si se rechaza, el rechazo se reinyecta en el grafo al leer el recurso y sube hasta el ErrorBoundary. Un solo trío —recurso, Suspense, ErrorBoundary— cubre los tres estados sin un solo if manual, cada uno en su plano.
Fallback y children
Dos ramas de la misma instancia: se muestra una u otra, pero los hijos existen desde el primer momento.
Contador de esperas
Cada recurso pendiente leído suma uno; al resolver resta. Cero esperas equivale a revelar.
Estado retenido
Los hijos nunca se destruyen al suspender, así que las revalidaciones conservan signals y DOM.
Junto a createResource, Solid ofrece createAsync —de @solidjs/router—, que envuelve una promesa en un accessor que suspende igual pero con una API más escueta, pensada para integrarse con el query y las action del router. Para Suspense es idéntico: un accessor cuya lectura pendiente mueve el contador. Elige createResource cuando necesites su mutate, su refetch o sus estados finos; createAsync cuando quieras solo leer un valor asíncrono y dejar que el framework gestione la caché por petición. Ambos hablan el mismo idioma con el límite, así que todo lo de esta lección se aplica sin cambios.
El límite solo ve las lecturas que ocurren durante el rastreo reactivo del render de sus hijos. Un resource() leído dentro de un manejador de evento, un setTimeout o cualquier callback que corra fuera de esa ejecución no incrementa el contador y no activa el fallback. Si necesitas reflejar esa carga en la UI, léelo en el render —directamente o a través de un derivado— para que el límite pueda contarlo. La regla es la misma que gobierna todo el grafo: solo cuenta lo que se lee mientras Solid está mirando. Y para lo local siempre te queda resource.loading, que puedes pintar con Show sin montar un límite.
La confusión que impide dominar Suspense es tratarlo como un if (cargando) fallback else hijos. No lo es, y verlo así explica todos sus comportamientos «raros». Suspense es un contador que un contexto lleva por debajo del árbol, y la aguja de ese contador la mueven las lecturas de recursos, no su existencia ni su creación. Por eso un recurso creado y no leído no suspende: nunca tocó el contador. Por eso dos lecturas del mismo recurso bajo el mismo límite son una sola espera: el recurso incrementa una vez por su estado pendiente, no una por cada lectura. Y por eso los hijos tienen que ejecutarse aunque no se vean: el contador solo puede subir si alguien lee, y para que alguien lea hay que correr el código que lee. Ejecutar-y-retener, en lugar de aplazar, es lo que permite que el límite se autodescubra sin que le declares dependencias, y es también lo que preserva el estado a través de las revalidaciones, porque los hijos nunca se destruyeron: solo estuvieron ocultos tras el fallback mientras la aguja no volvía a cero. Interioriza «contador de lecturas» y dejarás de preguntarte por qué tal Suspense no reacciona: siempre es porque nadie leyó un recurso pendiente donde el límite podía contarlo.
- Envuelve en un
Suspenseun componente que lea uncreateResourcecon unfetchde dos segundos; confirma que elfallbackaparece y luego se sustituye por el contenido. - Crea un recurso lento pero no lo leas dentro del límite; verifica que el
fallbackno aparece nunca y razona por qué. - Lee el mismo recurso en dos nodos distintos bajo un único
Suspensey comprueba que hay una sola espera coordinada, no dos. - Provoca una revalidación del recurso y observa que, al volver a suspender, el estado previo de los hijos se conserva en lugar de remontarse.
- Envuelve el
Suspenseen unErrorBoundary, rechaza elfetchdel recurso y confirma que el fallo salta al límite de error, no al de espera.