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