catchError: el primitivo de captura sin componente
catchError es el sustrato de bajo nivel sobre el que se construye ErrorBoundary: envuelve una computacion, desvia su fallo a un handler y no pinta ninguna UI. Captura no solo lo que lanza el cuerpo sino tambien lo que lanzan los efectos y memos creados dentro de su ambito. La decision clave vive en el handler: si retorna normal, el error queda consumido; si vuelve a lanzar, el nuevo error sube al limite exterior. Ese gesto de relanzar selectivamente es lo que permite capturar unos errores y dejar pasar otros, y como se relaciona con onError como observador.
ErrorBoundary es cómodo, pero a veces no quieres un fallback ni una región de UI: quieres capturar un fallo en código, decidir en el sitio si lo manejas o lo dejas subir, y devolver un valor de reserva. Ese es el terreno de catchError, el primitivo de bajo nivel sobre el que el propio ErrorBoundary está construido. Dominarlo te da el control fino de la captura: sin componente, sin fallback, con una decisión explícita —consumir o relanzar— en tus manos.
- Usar
catchErrorpara capturar el fallo de una computación sin pintar ninguna UI. - Saber que captura también los
throwde los efectos y memos creados dentro de su ámbito. - Decidir en el handler entre consumir —retornar normal— y relanzar —volver a lanzar—.
- Construir una política de “capturo unos, dejo pasar otros” y situar
onErrorcomo observador.
El try/catch del grafo, sin UI
catchError(tryFn, handler) ejecuta tryFn dentro de un ámbito de captura; si tryFn lanza, llama a handler con el error y devuelve undefined; si no lanza, devuelve su valor. No hay fallback ni nada que pintar: es lógica pura. Es, literalmente, lo que ErrorBoundary usa por dentro —su handler, en vez de registrar telemetría, fija un estado que hace pintar el fallback—.
import { catchError, createMemo } from "solid-js";
const config = createMemo(() =>
catchError(
() => parsear(entrada()), // puede lanzar si la entrada es invalida
(err) => reportar(err), // corre si lanza; el error queda consumido
) ?? CONFIG_POR_DEFECTO, // catchError devolvio undefined al fallar
);
Envolver el catchError en un createMemo lo hace reactivo: cada vez que entrada() cambia, la computación se re-ejecuta protegida, y un fallo puntual no derriba el grafo sino que cae en el handler y entrega el valor por defecto. Has convertido una operación arriesgada en una derivación que nunca lanza hacia fuera.
Captura lo que crea dentro
Lo que separa a catchError de un try/catch corriente es su alcance en el tiempo. No solo atrapa lo que tryFn lanza de forma síncrona: atrapa también lo que lancen los efectos y memos creados dentro de su ejecución, aunque lo hagan en un tick futuro. Esos computos heredan el ámbito de captura como owner, así que su throw viaja al mismo handler.
catchError(
() => {
createEffect(() => {
if (temperatura() > 100) throw new Error("sobrecalentamiento"); // tick futuro
});
},
(err) => registrar(err), // atrapa el throw del efecto, no solo el del cuerpo
);
Esta es la razón exacta por la que ErrorBoundary puede contener los fallos de los efectos de sus descendientes y no solo los de su render: hereda esta propiedad de catchError. La unidad de captura no es una línea de código en el tiempo, sino una región del árbol de owners en el espacio; todo lo que se ejecute como parte de ese subárbol —ahora o después— desemboca en el handler.
Consumir o relanzar: la decisión del handler
Aquí vive la potencia del primitivo. El destino del error lo decide lo que el handler hace: si retorna con normalidad, el error queda consumido y no sube más; si vuelve a lanzar —throw err u otro error—, ese nuevo error se propaga al siguiente ámbito de captura por encima. Con ese único gesto construyes una política selectiva: manejas los errores que sabes tratar y dejas pasar hacia un límite exterior los que no.
flowchart TD
TRY[catchError envuelve una computacion] --> RUN[corre tryFn y lo que crea dentro]
RUN -->|no lanza| VAL[devuelve el valor]
RUN -->|lanza| H[handler recibe el error]
H --> DEC{el handler que hace}
DEC -->|retorna normal| CONS[error consumido, no sube]
DEC -->|vuelve a lanzar| UP[el nuevo error sube al limite exterior]
style CONS fill:#a6e3a1,color:#11111b
style UP fill:#f38ba8,color:#11111bclass ErrorValidacion extends Error {}
const resultado = catchError(
() => validarYcalcular(datos()),
(err) => {
if (!(err instanceof ErrorValidacion)) throw err; // lo inesperado, hacia arriba
setAvisos((a) => [...a, err.message]); // lo esperado, manejado aqui
},
);
La regla de diseño que emerge es valiosa: captura cerca lo que entiendes, deja subir lo que no. Un error de validación es esperado y local —lo conviertes en un aviso—; un TypeError inesperado es un bug de programación que no debe morir silenciado en un handler cualquiera, sino subir hasta un ErrorBoundary de alto nivel que lo registre y muestre una pantalla de fallo. Relanzar es el mecanismo que impide que un handler demasiado ansioso se trague errores que no le corresponden.
Ese mismo primitivo te deja construir tus propias abstracciones de resiliencia sin pasar por un componente. Un patrón útil expone el fallo como un signal en vez de como un fallback: envuelves el cálculo en catchError, guardas el error en un estado y devuelves valor y error por separado, para que el consumidor decida cómo pintarlos.
import { catchError, createMemo, createSignal } from "solid-js";
function conError<T>(calc: () => T) {
const [error, setError] = createSignal<Error>();
const valor = createMemo(() =>
catchError(
() => { setError(undefined); return calc(); }, // limpia en cada intento
(e) => setError(e as Error), // captura sin relanzar
),
);
return { valor, error };
}
Es un ErrorBoundary sin UI: la misma captura, pero el resultado es un par reactivo que integras donde quieras —un banner, un borde rojo, una clase CSS— en lugar de una región sustituida. catchError es el ladrillo; la forma de la abstracción la eliges tú.
onError: observar sin decidir
Junto a catchError está onError(handler), que registra un manejador en el owner actual para cuando un descendiente lance. Su uso idiomático es la observación —telemetría, logging— sin construir recuperación ni fallback. Mientras catchError envuelve una computación concreta y decide su destino, onError se ata al ámbito ambiente y sirve para instrumentar.
import { onError } from "solid-js";
function ConTelemetria(props: { area: string; children: JSX.Element }) {
onError((err) => enviarASentry({ area: props.area, err })); // observa y registra
return <>{props.children}</>;
}
La división de trabajo limpia es: onError para registrar lo que ocurre, catchError —o el ErrorBoundary que lo envuelve— para decidir qué se hace con el error. Separar la observación de la política evita el antipatrón de mezclar telemetría y recuperación en un mismo handler que acaba haciendo mal las dos cosas.
Consumir
El handler retorna normal: el error muere ahi y catchError devuelve undefined en su lugar.
Relanzar
El handler vuelve a lanzar: el nuevo error sube al siguiente ambito de captura por encima.
Observar
onError registra en el owner ambiente para telemetria, sin decidir el destino del error.
Como hereda su mecanismo, catchError captura los throw síncronos de su ámbito y los de los efectos que nacen dentro, pero no los de manejadores de eventos ni los de callbacks async sueltos —un setTimeout, un .then a pelo—, que corren fuera del owner. Para enrutar esos, el patrón sigue siendo el mismo: devuélvelos al grafo por un signal que leas en una computación o por un createResource cuyo rechazo baje al ámbito de captura.
El salto de madurez es dejar de ver ErrorBoundary como la forma de manejar errores y verlo como una de las muchas cosas que puedes construir sobre un primitivo más fundamental. catchError expone la operación desnuda: toma una región del grafo, desvía su fallo a una función, y deja que esa función decida el destino del error con la más simple de las semánticas —retornar consume, relanzar propaga—. Todo lo demás son interpretaciones de ese álgebra. ErrorBoundary es la interpretación visual: su handler fija un estado que hace pintar un fallback, y su reset re-monta los hijos. Pero hay otras igual de válidas que ningún componente te da: convertir una computación arriesgada en una derivación que nunca lanza devolviendo un valor de reserva; enrutar por tipo, manejando los errores de dominio en el sitio y relanzando los bugs para que suban a un límite global que los registre; instrumentar con onError sin tocar la UI. La decisión de relanzar es la más infravalorada de todas, porque es la que convierte un puñado de handlers dispersos en una jerarquía de responsabilidad: cada nivel captura lo que entiende y confía el resto al de arriba, exactamente como el catch más interno de un lenguaje con excepciones decide si resuelve o repropaga. Cuando piensas en errores como valores que fluyen por el árbol de owners y en catchError como el punto donde interceptas ese flujo para consumirlo o dejarlo pasar, ganas una libertad que el componente solo te insinuaba: diseñar dónde vive cada decisión, con o sin pantalla de por medio.
La heurística de elección es simple. Si el fallo debe traducirse en una región de pantalla —un mensaje, un botón de reintento—, usa ErrorBoundary, que ya trae fallback y reset. Si el fallo debe resolverse en código —un valor de reserva, una decisión de relanzar, un error convertido en signal—, usa catchError directamente. Y recuerda que uno está hecho del otro: nunca es una elección de potencia, solo de si necesitas o no una UI de por medio.
- Envuelve un
parsearque puede lanzar en uncatchErrordentro de uncreateMemoy devuelve un valor por defecto; confirma que un cambio inválido no derriba el grafo. - Crea un
createEffectdentro deltryFnque lance en un tick futuro y verifica que el handler decatchErrortambién lo atrapa. - Escribe un handler que relance con
throw errlos errores que no sean de una clase propia y comprueba que suben a unErrorBoundaryexterior. - Maneja en el sitio los errores de esa clase propia acumulándolos en un signal de avisos y verifica que no llegan al límite exterior.
- Añade un
onErroren un ancestro para registrar telemetría y observa que la decisión de consumir o relanzar sigue viviendo en elcatchError, no en él.