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.
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" aunEsta 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);});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.