Del fetcher al límite: el viaje de un error async
Anatomia precisa de como un rechazo dentro del fetcher de createResource se convierte en un throw dentro del grafo reactivo y sube por el arbol de owners hasta el ErrorBoundary mas cercano. La captura es diferida: createResource intercepta la promesa, guarda el fallo en estado errored, y solo cuando un consumidor lee el accessor en el render se relanza. Por que el limite que atrapa es el que rodea al lector y no al recurso, y como inspeccionar con resource.error, resource.latest y resource.state sin volver a lanzar.
Un fetch que falla no derriba tu aplicación por sí solo: createResource intercepta el rechazo, lo guarda y espera. El error no viaja en el instante en que la promesa se rompe, sino en el instante en que alguien lee el recurso dentro del grafo. Ese matiz —la captura es diferida hasta la lectura— explica por qué el ErrorBoundary que atrapa el fallo es el que rodea al lector, no el que rodea a la llamada createResource. Este nivel disecciona ese viaje paso a paso.
- Trazar el camino completo desde el rechazo del fetcher hasta el
fallbackdel límite. - Entender que el error se materializa al leer
data(), no al rechazarse la promesa. - Distinguir leer con
data()—que relanza— de inspeccionar conresource.erroryresource.latest. - Situar el
ErrorBoundaryen torno al consumidor del recurso para que lo capture.
El fetcher rechaza, el recurso guarda
createResource(source, fetcher) toma un fetcher que devuelve una promesa. Cuando esa promesa se rechaza —o cuando el cuerpo del fetcher lanza de forma síncrona antes de devolverla—, la maquinaria del recurso atrapa el rechazo ella misma: es dueña del .then/.catch de la promesa. Transiciona el recurso al estado errored y deposita el error en resource.error. No hay ErrorBoundary implicado todavía, ni una promesa sin manejar en consola: el fallo queda guardado como un dato, latente.
import { createResource } from "solid-js";
async function cargarUsuario(id: string) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); // rechazo controlado
return res.json();
}
const [usuario] = createResource(() => id(), cargarUsuario);
// si el fetcher rechaza, usuario.error queda listo; nada ha "explotado" aun
Esta captura silenciosa es la primera pieza del puente entre dos mundos: el asíncrono —donde una promesa se rompe en un tick futuro sin owner que la contenga— y el síncrono —donde ErrorBoundary solo sabe atrapar throw durante el render y los efectos—.
La lectura relanza dentro del grafo
El accessor usuario() no es un getter inocente. Cuando el recurso está en estado errored, llamarlo vuelve a lanzar el error guardado, de forma síncrona. Y como esa llamada ocurre dentro de una computación reactiva —el render de un componente, un createMemo, un efecto—, el throw sucede dentro del grafo: se comporta igual que cualquier excepción síncrona al renderizar y sube por el árbol de owners hasta el ErrorBoundary más cercano.
import { ErrorBoundary, createResource } from "solid-js";
function Perfil(props: { id: string }) {
const [usuario] = createResource(() => props.id, cargarUsuario);
return (
<ErrorBoundary fallback={(err) => <p role="alert">{err.message}</p>}>
<h1>{usuario()?.nombre}</h1> {/* leer usuario() en errored relanza aqui */}
</ErrorBoundary>
);
}
Ese es el mecanismo completo: createResource convierte un rechazo diferido en un throw en la lectura. La conversión de “se rompió antes” a “lanza ahora, aquí” es lo que permite que la infraestructura síncrona de límites capture un fallo que nació en el mundo de las promesas.
Cualquier lectura reactiva vale como detonante, no solo el render. Leer usuario() dentro de un createEffect o un createMemo en estado errored relanza igual, porque todos son computaciones del grafo con un owner que encadena hacia un límite.
createEffect(() => {
const u = usuario(); // leer en un efecto tambien relanza si esta en errored
if (u) registrarVista(u.id);
});
flowchart TD F[fetcher rechaza la promesa] --> CAP[createResource atrapa y guarda el error] CAP --> EST[estado errored y resource.error listo] EST --> READ[un consumidor lee data en el render] READ --> THROW[la lectura relanza el error en el grafo] THROW --> OWN[sube por el arbol de owners] OWN --> EB[ErrorBoundary mas cercano al lector] style THROW fill:#f38ba8,color:#11111b style EB fill:#a6e3a1,color:#11111b
El límite del lector, no el del recurso
Del hecho de que el throw nazca en la lectura se deriva una consecuencia que sorprende a casi todos: el límite que captura es el ancestro más cercano de la lectura, no de la creación del recurso. Cuando creas y lees el recurso en el mismo subárbol, ambos coinciden y nadie lo nota. Pero si creas el recurso arriba y lo lees abajo, el ErrorBoundary debe envolver al consumidor.
function Padre() {
const [datos] = createResource(fuente, cargar); // creado aqui, sin limite
return <Hijo datos={datos} />; // el error no nace aqui: nadie lee todavia
}
function Hijo(props: { datos: Resource<Dato> }) {
return (
<ErrorBoundary fallback={(e) => <Fallo msg={e.message} />}>
<span>{props.datos().valor}</span> {/* aqui se lee: aqui se captura */}
</ErrorBoundary>
);
}
Interiorizar esto reordena tu instinto: no pones el límite “donde pediste los datos”, lo pones “donde los pintas”. El recurso es un contenedor de estado que viaja; el error solo cobra vida cuando ese estado se toca en una computación viva.
Inspeccionar sin lanzar
A veces no quieres que la lectura lance: quieres ramificar tú mismo sobre el fallo. El recurso expone tres vías de inspección que no relanzan. resource.error devuelve el objeto de error —o undefined— sin propagar nada. resource.latest entrega el último valor resuelto sin disparar Suspense ni lanzar en error. Y resource.state te da la etiqueta de la máquina de estados: unresolved, pending, ready, refreshing o errored.
<Show when={!usuario.error} fallback={<p>No se pudo cargar</p>}>
<h1>{usuario()?.nombre}</h1>
</Show>
Las tres vías se combinan para pintar una interfaz completa sin un solo throw. resource.loading mueve el spinner, resource.error decide el mensaje de fallo, y resource.latest conserva el último dato bueno mientras una revalidación está en vuelo, de modo que el usuario no ve un hueco durante el refresco.
<Switch fallback={<Vista datos={usuario.latest} />}>
<Match when={usuario.loading}><Spinner /></Match>
<Match when={usuario.error}><Aviso err={usuario.error} /></Match>
</Switch>
La regla de decisión es nítida: usa data() cuando quieras delegar el fallo a un ErrorBoundary que gobierne toda la región; usa resource.error cuando quieras manejarlo en el sitio con un mensaje inline. Son dos estrategias, no dos herramientas rivales: la primera centraliza, la segunda localiza, y una misma aplicación usa cada una donde le conviene.
error como dato
Mientras nadie lea, el fallo vive en resource.error como un valor inerte, sin propagarse.
lectura que lanza
data() en estado errored relanza sincronamente y el throw sube por los owners.
via de inspeccion
resource.error, resource.latest y resource.state leen el estado sin volver a lanzar.
En una carga feliz, resource.state recorre unresolved hacia pending y luego ready; en una fallida, pending hacia errored. Un refetch sobre un recurso ya resuelto intercala refreshing antes de volver a ready o caer en errored. La bandera resource.loading vale verdadero en pending y en refreshing, de modo que un mismo spinner cubre la primera carga y las revalidaciones sin que tengas que distinguirlas a mano.
Cuando el primer argumento de createResource es un accessor que devuelve undefined, null o false, el fetcher no corre: el recurso permanece en estado unresolved, sin datos, sin error y sin cargar. Es el mecanismo idiomático de la carga condicional —no pidas el perfil hasta que exista un id—. Y tiene una consecuencia para los errores: mientras la fuente sea falsy no hay nada que pueda fallar ni lectura que lance, así que el ErrorBoundary nunca se dispara por un recurso que todavía no ha empezado a cargar.
La confusión que este nivel disuelve es imaginar que el error “sale disparado” cuando la promesa se rompe. No lo hace. createResource es, entre otras cosas, un adaptador de dominios: toma el mundo asíncrono —donde los fallos ocurren fuera de todo owner, en ticks que ningún try/catch del grafo alcanza— y lo traduce al mundo síncrono guardando el fallo como estado y difiriendo su reaparición hasta que una computación lo lea. En el intervalo entre el rechazo y la primera lectura, el error existe pero es inofensivo: es un campo del recurso, tan pasivo como su valor o su bandera de carga. La lectura es el acto que lo reconvierte en excepción, y por eso el álgebra de la captura no gira en torno a dónde pediste los datos sino a dónde los consumes en una computación viva. Esto tiene un corolario práctico enorme: puedes crear recursos en la raíz de tu aplicación, pasarlos por props o contexto, y decidir en cada punto de consumo si quieres que ese consumo delegue el fallo a un límite —leyendo con data()— o lo maneje localmente —inspeccionando con resource.error—. El recurso no dicta la política de errores; la dicta quien lo lee. Cuando ves el error como un dato que viaja con el recurso y solo se vuelve peligroso al tocarlo, dejas de colocar límites por superstición y empiezas a colocarlos con precisión quirúrgica alrededor de cada lector.
- Escribe un fetcher que lance
HTTP 500y comprueba queresource.errorqueda listo sin que nada aparezca en pantalla hasta que leesdata(). - Envuelve la lectura en un
ErrorBoundaryy verifica que elfallbackaparece solo al leer, no al rechazarse la promesa. - Crea el recurso en un componente padre sin límite y léelo en un hijo con su propio
ErrorBoundary; confirma que captura el hijo. - Sustituye la lectura con
data()por una rama conresource.errory unresource.latestde reserva; observa que ya no salta el límite. - Registra en un efecto los cambios de
resource.statey anota la secuencia exacta que recorre desdeunresolvedhastaerrored.