wandres.dev
CREATERESOURCE · async como primitivo

La fuente reactiva: disparar y descartar peticiones en vuelo

El primer argumento de createResource no es un valor, es una función reactiva rastreada: cualquier signal que leas dentro se vuelve dependencia, así que puedes combinar varias fuentes en una. Su superpoder silencioso es la resolución de condiciones de carrera: cuando la fuente cambia durante una carga, el resultado de la petición anterior se descarta, nunca sobrescribe al último. Para cancelar la red de verdad y no solo ignorar su respuesta, un onCleanup dentro del fetcher aborta con AbortController.

⏱ 18 min

La firma con fuente esconde más profundidad de la que aparenta. Su primer argumento no es un valor que pasas: es una función reactiva que Solid ejecuta dentro de un ámbito rastreado, y de esa naturaleza se derivan dos superpoderes. El primero es que puedes combinar varios signals en una sola fuente, y el resource reaccionará a cualquiera de ellos. El segundo, más silencioso y más valioso, es que el resource resuelve por ti la condición de carrera más clásica de la carga asíncrona —la respuesta lenta que llega tarde y pisa a la rápida— sin que escribas una línea para ello. Esta lección desmonta ambos, y termina con la cancelación real de la red, la capa que el descarte automático no cubre.

🎯 Al terminar esta lección sabrás
  • Ver la fuente como una dependencia reactiva que puede combinar varios signals en uno.
  • Entender que un cambio de fuente durante una carga descarta el resultado de la anterior.
  • Reconocer la condición de carrera que el resource elimina por defecto, sin coordinación manual.
  • Añadir cancelación real de red con AbortController atado al ciclo del fetcher.

La fuente es una dependencia, no un valor

El primer argumento de createResource no es un valor: es una función reactiva. Solid la ejecuta dentro de un ámbito rastreado, así que cualquier signal que leas dentro se convierte en dependencia del resource. Eso te deja combinar varias fuentes en una sola expresión.

const [q, setQ] = createSignal("");
const [pagina, setPagina] = createSignal(1);

// la fuente es un objeto derivado de dos signals
const [resultados] = createResource(
  () => ({ q: q(), pagina: pagina() }),
  ({ q, pagina }) => buscar(q, pagina)
);
// cambiar q() o pagina() recomputa la fuente y re-dispara la busqueda

Cuando cualquiera de los signals leídos en la fuente cambia, Solid recomputa la fuente, la compara —por identidad— con la anterior y, si difiere, re-ejecuta el fetcher con el nuevo valor. La orquestación «vuelve a pedir cuando cambie cualquiera de estas entradas», que en otros modelos son varias líneas de efecto propensas a error, aquí es la forma natural del primitivo.

Esta misma naturaleza habilita un patrón elegante: los recursos dependientes, donde el argumento de una carga es el resultado de otra. Como la fuente es una función reactiva, puedes leer dentro de ella el valor de otro resource, y Solid encadenará las dos cargas sin coordinación manual.

const [usuario] = createResource(idUsuario, traerUsuario);

// la fuente del segundo resource lee el primero: encadenamiento reactivo
const [pedidos] = createResource(
  () => usuario()?.id,        // undefined mientras 'usuario' no ha resuelto: no pide
  (id) => traerPedidos(id)
);

Mientras usuario() sea undefined, la fuente de pedidos es falsy y no se lanza ninguna petición; en cuanto el usuario resuelve, su id dispara la segunda carga. Has expresado la dependencia «primero el usuario, luego sus pedidos» apoyándote solo en las dos reglas que ya conoces —la fuente reactiva dispara, la fuente falsy pospone—, sin un solo if ni un await encadenado a mano.

Reconoce, eso sí, que este encadenamiento es una cascada: dos viajes de red en serie, porque el segundo necesita el resultado del primero. A veces es inevitable —no hay forma de pedir los pedidos sin el id del usuario—, pero cuando dos cargas no dependen de verdad una de otra, no las encadenes: declara dos resources independientes y correrán en paralelo. Reserva la fuente que lee otro resource para las dependencias reales, no para las aparentes.

Qué cuenta como un cambio de fuente

Conviene precisar cuándo exactamente el resource decide re-disparar, porque la respuesta gobierna cuántas peticiones lanzas. Solid compara el valor nuevo de la fuente con el anterior por igualdad referencial, la misma regla que rige a un signal. Si la fuente devuelve un primitivo —un número, una cadena—, dos valores iguales no disparan: pasar de id 2 a 2 no hace nada. Si devuelve un objeto nuevo en cada recomputación, en cambio, cualquier cambio de cualquier entrada dispara, porque el objeto recién creado nunca es referencialmente igual al anterior.

// fuente primitiva: se deduplica sola
const [datos] = createResource(id, traer);   // pasar id de 2 a 2 no re-dispara

// fuente objeto: cada recomputacion es un objeto nuevo -> siempre dispara
const [busqueda] = createResource(
  () => ({ q: q(), pagina: pagina() }),       // objeto fresco en cada vuelta
  hacerBusqueda
);

Las dos conductas son deseables en su contexto. Para una clave simple, la deduplicación por primitivo te ahorra peticiones redundantes sin que hagas nada. Para una fuente compuesta, quieres que cualquier cambio dispare, y el objeto fresco lo garantiza. Lo único a evitar es lo contrario a tu intención: mutar un objeto de fuente en lugar de crear uno nuevo dejaría al resource ciego al cambio, porque la referencia seguiría siendo la misma. Y como la fuente corre en un ámbito rastreado, solo los signals que lees dentro de ella son dependencias; una lectura envuelta en untrack no suscribiría el resource a ese signal.

Vale la pena verlo como una palanca de diseño y no como un tecnicismo. Eliges el comportamiento de deduplicación con la forma del valor que devuelve la fuente, sin ninguna opción ni bandera. Si quieres que dos consultas idénticas no repitan la petición, haz que tu fuente colapse a una clave primitiva estable —serializando los parámetros a una cadena, por ejemplo—. Si quieres lo contrario, que cada intento dispare aunque los parámetros coincidan, un objeto fresco te lo da. La igualdad referencial, que en otros contextos es una trampa, aquí es el interruptor con el que decides.

El resultado obsoleto se descarta

Aquí aparece la garantía más valiosa —y más callada— del resource. Imagina que tecleas rápido en un buscador: la petición de «sol» sale, y antes de que responda ya has escrito «solid» y sale una segunda. ¿Qué pasa si la primera, más lenta, responde después de la segunda?

En un efecto ingenuo, la respuesta tardía de «sol» sobrescribiría a la de «solid» y verías el resultado equivocado bajo el texto correcto: la clásica condición de carrera. Es un bug real, difícil de reproducir y peor de depurar, que ha costado incontables horas en aplicaciones de todo tamaño.

createResource lo elimina de raíz. Cuando la fuente cambia, la petición anterior queda marcada como obsoleta; cuando esa petición resuelve, su valor se ignora —no se escribe en el resource—. Solo cuenta la última fuente. No hay una línea que escribir para conseguirlo: es el comportamiento por defecto del primitivo.

El mecanismo interno es tan simple como robusto: cada ejecución del fetcher queda asociada a la versión de la fuente que la originó, y cuando su promesa resuelve, el resource comprueba si esa versión sigue siendo la vigente. Si una fuente más nueva la ha reemplazado, el resultado se descarta en silencio. No hay temporizadores que gestionar ni identificadores de petición que compares a mano —ese trabajo tedioso y fácil de estropear que el modelo imperativo te obliga a escribir—; la versión la lleva el propio sistema reactivo, y por eso la garantía es total y no depende de que recuerdes aplicarla en cada carga.

flowchart TD
Q1[fuente sol lanza peticion 1] --> W1[peticion 1 lenta en vuelo]
Q2[fuente solid lanza peticion 2] --> W2[peticion 2 rapida en vuelo]
W2 -->|resuelve primero| R[resource muestra solid]
W1 -->|resuelve tarde| D[resultado obsoleto descartado]
style R fill:#a6e3a1,color:#11111b
style D fill:#f38ba8,color:#11111b

La consecuencia práctica es enorme: puedes atar un resource directamente al valor de un input y dejar que dispare en cada tecla, sin miedo a que las respuestas lleguen desordenadas. El resource garantiza que lo que ves siempre corresponde a la última fuente, no a la última respuesta. Ese desacople entre «orden de llegada» y «orden de relevancia» es exactamente lo que hace tan frágil el modelo imperativo y tan robusto el reactivo.

Conviene separar dos cosas que a menudo se confunden. El descarte de resultados obsoletos es una garantía de corrección: nunca verás el dato equivocado. Antirebotar la fuente —esperar a que el usuario deje de teclear antes de disparar— es una optimización de coste: reduce cuántas peticiones lanzas. Son ortogonales. El resource te da la primera gratis; la segunda la añades tú si la latencia o el volumen lo justifican, envolviendo la fuente en un signal antirebotado. Pero no confundas la optimización con la corrección: aunque no antirebotes nada y dispares en cada pulsación, el resultado que muestres seguirá siendo siempre el correcto.

Este es, quizá, el argumento más contundente a favor de modelar la carga como un resource en lugar de como un efecto: no es solo que escribas menos código, es que el código que no escribes es precisamente el más difícil de escribir bien. La lógica que descarta respuestas obsoletas es sutil, se prueba fatal —porque solo falla bajo latencias que tu máquina rara vez reproduce— y cuando falla lo hace en silencio. Delegarla en el primitivo borra de tu base de código una familia entera de bugs que, con suerte, ni siquiera tendrás que aprender a reconocer.

Cancelar la red de verdad con AbortController

Descartar el resultado evita el bug visual, pero la petición obsoleta sigue viajando: consume ancho de banda y puede cargar tu servidor con trabajo que ya no importa. Para cancelarla de verdad, Solid te da un gancho preciso. El fetcher corre dentro de un ámbito reactivo, así que un onCleanup registrado dentro del fetcher se ejecuta cuando esa carga queda obsoleta o el resource se dispone. Ese es el sitio para abortar.

import { createResource, onCleanup } from "solid-js";

const [datos] = createResource(query, async (q) => {
  const controlador = new AbortController();
  onCleanup(() => controlador.abort()); // corre si la fuente cambia antes de resolver
  const res = await fetch(`/api/buscar?q=${q}`, { signal: controlador.signal });
  return res.json();
});

Ahora, cuando la fuente cambia mientras una carga viaja, el onCleanup de esa carga dispara abort() sobre su AbortController, y la petición HTTP se cancela en el navegador antes de completarse. Combinas así las dos capas de protección: Solid descarta el resultado obsoleto a nivel de estado, y tú cancelas el trabajo a nivel de transporte. La primera capa te la regala el primitivo; la segunda la añades con cuatro líneas cuando la petición es cara o frecuente.

¿Cuándo merece la pena la segunda capa? Siempre que la petición cancelada tenga un coste real que no quieras pagar: peticiones grandes, servidores bajo presión, funciones de servidor que facturan por invocación, o simplemente un buscador que dispara decenas de veces por segundo. Si la carga es barata y rara, el descarte de estado basta y el AbortController es ceremonia innecesaria. La regla es pragmática: añade la cancelación de transporte cuando el trabajo desperdiciado importe, y confía solo en el descarte cuando no. El AbortSignal, además, se propaga de forma natural a través de fetch y de cualquier API que lo acepte, así que la cancelación viaja hasta el borde de tu sistema sin lógica extra por tu parte.

Hay un segundo momento en que ese onCleanup corre, y conviene tenerlo presente: cuando el resource se dispone —porque el componente que lo creó se desmonta—. Una petición en vuelo al abandonar una pantalla se cancela igual que una obsoleta, sin que tú lo orquestes. Es el mismo sistema de propietarios que gobierna todos los onCleanup de Solid, aplicado aquí a la carga de datos: el fetcher no es una isla, participa del árbol de ciclo de vida como cualquier otro efecto, y por eso su cancelación se ata a la vida de quien lo creó tanto como al ritmo de su fuente.

Una última cautela sobre la cancelación: abortar una petición de lectura es inofensivo, pero cortar a mitad una operación que escribe en el servidor puede dejar estado a medias. Reserva el AbortController para cargas idempotentes —consultas, búsquedas, listados— y piénsatelo dos veces antes de abortar algo que muta datos, donde a menudo prefieres dejar que la petición termine aunque descartes su resultado en el cliente. La regla del resource protege tu interfaz; la integridad del servidor sigue siendo tuya.

🎛️

Fuente combinada

Devuelve un objeto derivado de varios signals y el resource depende de todos. Un cambio en cualquiera recomputa la fuente y re-dispara.

🏁

Descarte de obsoletos

Si la fuente cambia durante una carga, el resultado tardio de la peticion vieja se ignora. La condicion de carrera se resuelve sola.

✂️

AbortController

Un onCleanup dentro del fetcher aborta la peticion cuando queda obsoleta, cancelando la red y no solo ignorando su respuesta.

⚠️
Registra el onCleanup antes del primer await

El truco de la cancelación depende de un detalle de timing: onCleanup debe llamarse síncronamente, en la primera línea del fetcher, antes de cualquier await. La razón es que el contexto de propietario reactivo —el dueño al que onCleanup se engancha— está disponible mientras el fetcher corre de forma síncrona, pero tras el primer await esa ejecución ya no está en el ámbito rastreado. Si intentas registrar la limpieza después de esperar la respuesta, no se engancha a nada y la cancelación nunca corre. Crea el AbortController y registra su abort en las dos primeras líneas, siempre; todo lo asíncrono viene después.

La reactividad convierte una carrera que combates en una invariante que recibes gratis

Detente en lo que acaba de pasar, porque es más profundo de lo que parece. Las condiciones de carrera en la carga de datos son uno de esos problemas que todo el mundo sabe que existen, casi nadie maneja bien, y que producen bugs que solo se manifiestan bajo latencia variable —es decir, en producción, con usuarios reales, nunca en tu máquina—. El enfoque imperativo las trata como algo que combatir caso por caso: guardas un identificador de la última petición, comparas al resolver, descartas si no coincide, y rezas por no haberte dejado ningún camino. Es defensa perimetral, y como toda defensa perimetral, falla en el hueco que olvidaste. createResource no defiende contra la carrera: la hace imposible por construcción. Y la razón por la que puede hacerlo es que ha modelado el problema en el lenguaje correcto. Una condición de carrera es, en el fondo, una confusión entre dos ordenamientos distintos —el orden en que las peticiones salen, dictado por los cambios de la fuente, y el orden en que llegan, dictado por la red y su latencia caprichosa—. El código imperativo mezcla ambos porque escribe el valor cuando la respuesta llega, atándose sin querer al orden de llegada. La reactividad los separa limpiamente: el resource está ligado a la fuente, no a la respuesta. La fuente tiene un orden bien definido y determinista —el último valor es el último valor—, y el resource se compromete solo con él: sea cual sea la respuesta que llegue, solo la de la fuente vigente se escribe. La red puede entregar sus paquetes en el orden que quiera; da igual, porque el resource ya no escucha «qué llegó» sino «qué es relevante ahora». Esta es la clase de garantía que distingue un primitivo bien diseñado de una utilidad conveniente: no te da una herramienta para resolver el problema, te da un modelo en el que el problema no puede plantearse. Y se generaliza más allá de la red —cualquier operación asíncrona disparada por estado reactivo hereda la misma invariante—. Cuando programas así, dejas de llevar la cuenta mental de «¿y si esta responde tarde?» en cada carga, porque la pregunta ha dejado de tener sentido. Ese silencio en tu cabeza, ese problema que ya no tienes que pensar, es la medida exacta de lo que un buen primitivo te regala.

⚔️ Provoca y domina la carrera
  1. Ata un resource al valor de un input de búsqueda y teclea rápido con latencia de red simulada; confirma que el resultado siempre corresponde al último texto, no a la última respuesta.
  2. Combina dos signals —query y página— en una sola fuente que devuelva un objeto, y verifica que cambiar cualquiera re-dispara el fetcher.
  3. Añade un AbortController con onCleanup dentro del fetcher y observa en las herramientas de red que las peticiones obsoletas se cancelan.
  4. Mueve el onCleanup a después del await a propósito, comprueba que la cancelación deja de funcionar, y explica por qué en términos de propietario reactivo.
  5. Reescribe la misma carga con un createEffect ingenuo y un fetch, reproduce la condición de carrera, y contrasta cuánto código te ahorró el resource.