wandres.dev
DERIVACIONES ASYNC · createAsync y el futuro

createAsync: una derivación asíncrona que se lee como un signal

createAsync, el primitivo asíncrono de Solid Router y anticipo del modelo de Solid 2.0: una promesa envuelta en una derivación reactiva que se lee llamándola, igual que cualquier signal. Cómo importarlo, cómo rastrea sus dependencias de forma automática como un memo, por qué debe leer sus fuentes antes del primer await, y cómo se integra con Suspense para que la lectura devuelva siempre un valor y nunca un estado de carga a mano.

⏱ 16 min

Durante años el modelo asíncrono de Solid fue createResource: potente, pero con una forma —la tupla, la fuente explícita, los estados como propiedades— que delataba su parentesco con las herramientas de datos de React. createAsync, que llega desde Solid Router y prefigura el núcleo de Solid 2.0, borra esa distancia. Es una derivación asíncrona: escribes una función que devuelve una promesa y recibes a cambio un accessor que se lee llamándolo, exactamente como un signal. Cuando el dato aún no está, la lectura no te devuelve undefined ni un booleano de carga: suspende. Toda la incomodidad de coordinar lo asíncrono a mano se disuelve en un primitivo que se comporta como si el valor ya estuviera ahí.

🎯 Al terminar esta lección sabrás
  • Importar createAsync de Solid Router y crear una derivación que se lee llamándola.
  • Entender que su función rastrea dependencias de forma automática, igual que un createMemo.
  • Interiorizar por qué las fuentes reactivas deben leerse antes del primer await.
  • Ver cómo la integración con Suspense hace que la lectura devuelva siempre un valor.

De la promesa a la derivación

El punto de partida es una función que devuelve una promesa. createAsync la envuelve en una derivación reactiva y te entrega un accessor. No hay tupla, no hay opciones obligatorias, no hay fuente que declarar por separado: la dependencia se descubre leyendo, como en cualquier computación de Solid.

import { createAsync } from "@solidjs/router";

function Perfil(props: { id: number }) {
  // se lee llamandolo, como un signal
  const usuario = createAsync(() => getUsuario(props.id));

  return (
    <Suspense fallback={<Spinner />}>
      <h1>{usuario().nombre}</h1>
    </Suspense>
  );
}

Lee usuario() dentro del JSX y observa lo que no está: ningún usuario.loading, ningún guardia if (!usuario()). Cuando props.id cambia, la función se vuelve a ejecutar, pide el usuario nuevo y el accessor entrega el resultado cuando llega. La lectura es idéntica a la de un signal ordinario; la asincronía queda debajo, invisible en el punto de uso.

El compañero natural de createAsync es query —el envoltorio de deduplicación y caché de Solid Router, antes llamado cache—. query memoiza la petición por su clave, la comparte entre lecturas y la hace revalidable; createAsync se limita a leerla dentro del grafo reactivo. Esta pareja es el patrón canónico del estado del servidor en Solid moderno.

import { query, createAsync } from "@solidjs/router";

// query dedupe y cachea por clave; se integra con preload y revalidacion
const getUsuario = query(async (id: number): Promise<Usuario> => {
  const r = await fetch(`/api/usuarios/${id}`);
  return r.json();
}, "usuario");

El rastreo es automático, como en un memo

Aquí está la idea que reordena todo lo demás: la función que pasas a createAsync rastrea sus dependencias sola, igual que la de un createMemo. Cualquier signal, prop reactiva o accessor que leas de forma síncrona dentro de ella queda suscrito, y el primitivo se re-ejecuta cuando esa fuente cambia. No declaras la dependencia; la contraes por el acto de leerla.

Eso tiene una consecuencia afilada que separa a quien entiende el modelo de quien lo sufre: el rastreo solo captura lo que se lee antes del primer await. En cuanto una función asíncrona suspende en un await, sale del contexto de rastreo síncrono; los signals leídos después ya no cuentan como dependencias. Es la misma trampa que acecha en los efectos asíncronos, y la disciplina es idéntica: lee tus fuentes arriba, antes de suspender.

// MAL: props.id se lee despues del await y NO queda rastreado
const datos = createAsync(async () => {
  await precalentar();
  return getUsuario(props.id); // esta lectura no crea dependencia
});

// BIEN: captura la dependencia de forma sincrona, antes del primer await
const datos = createAsync(async () => {
  const id = props.id;   // rastreado
  await precalentar();
  return getUsuario(id);
});

En la versión defectuosa, cambiar props.id no vuelve a disparar la derivación: la dependencia se perdió tras el await. En la correcta, id se leyó en terreno síncrono y la reactividad quedó cosida. Grábate la regla como reflejo, porque el compilador no te avisará.

⚠️
El await es una frontera de rastreo, no solo de tiempo

Un await no pausa únicamente la ejecución: interrumpe el contexto reactivo. Toda fuente que tu derivación necesite para saber cuándo recalcularse tiene que leerse antes de esa frontera. Si un dato solo se conoce tras una petición previa, encadénalo en otra derivación (lo verás en la lección de composición) en lugar de leerlo tarde. Leer una dependencia después del primer await es la causa número uno de derivaciones que “no reaccionan” y de horas perdidas buscando un bug que no está en la red, sino en el orden de dos líneas.

Suspense integrado: el valor siempre está

La segunda mitad del diseño es que createAsync suspende por defecto. Cuando lees el accessor y el valor todavía no resolvió, la lectura lanza hacia el Suspense más cercano, que muestra su fallback mientras tanto. Por eso usuario() tiene tipo Usuario y no Usuario | undefined: dentro de la frontera, en el momento en que el contenido se pinta, el valor ya existe. La carga deja de ser un estado que compruebas y pasa a ser una frontera que declaras una vez.

<Suspense fallback={<p>Cargando perfil…</p>}>
  {/* dentro de aqui, usuario() es un Usuario, nunca undefined */}
  <h1>{usuario().nombre}</h1>
  <p>{usuario().bio}</p>
</Suspense>

Los errores viajan por el mismo canal declarativo: una promesa rechazada se propaga al ErrorBoundary más cercano, igual que la carga viaja a Suspense. Y si prefieres arrancar con un valor sin suspender la primera vez —típico en SSR o para evitar un parpadeo—, la opción initialValue siembra el accessor con un dato inicial y desactiva la suspensión de arranque.

const total = createAsync(() => contarPendientes(), { initialValue: 0 });
// total() vale 0 desde el primer render; nunca suspende al inicio

Leer fuera del render: eventos y contexto reactivo

La lectura que suspende solo tiene sentido dentro de un contexto reactivo —el JSX, un memo, un efecto— donde una frontera Suspense puede atrapar lo pendiente. Si llamas al accessor desde un manejador de eventos o desde código que no rastrea, no hay nada que suspenda: la lectura devuelve el último valor resuelto, o undefined si aún no llegó ninguno. Por eso, para usar un valor asíncrono dentro de un evento, léelo por latest y defiende el caso de que todavía no exista.

const usuario = createAsync(() => getUsuario(props.id));

function alGuardar() {
  // en un evento no hay Suspense que atrape lo pendiente
  const u = usuario.latest;   // valor actual o previo, sin suspender
  if (!u) return;             // defiende el caso de que aun no haya llegado
  guardarPreferencias(u.id);
}

La regla operativa cabe en una línea: dentro del render, lee llamando y deja que Suspense haga su trabajo; fuera del render, lee latest y defiende el undefined a mano, porque ahí no hay frontera que te cubra. Confundir los dos contextos es la causa más común de un undefined inesperado que solo aparece al pulsar un botón.

flowchart LR
S[signal o prop reactiva] -->|lectura sincrona| F[funcion async de createAsync]
F -->|devuelve promesa| P[promesa pendiente]
P -->|resuelve| A[accessor con valor]
A -->|se lee llamandolo| J[JSX]
A -.aun pendiente.-> B[Suspense muestra fallback]
A -.rechazada.-> E[ErrorBoundary captura]
style A fill:#a6e3a1,color:#11111b
style B fill:#f9e2af,color:#11111b
style E fill:#f38ba8,color:#11111b
📶

Se lee llamando

Un accessor como cualquier signal. usuario() devuelve el valor; no hay tupla ni propiedades de estado que consultar en el punto de uso.

🧲

Rastreo automatico

Las dependencias se descubren leyendo, como en un memo. Se re-ejecuta sola cuando cambia una fuente leida antes del primer await.

🪂

Suspense por defecto

Leer un valor pendiente suspende hacia la frontera mas cercana. La carga es una frontera declarada, no un booleano comprobado.

createAsync es una promesa que finge ser un signal, y esa ficción es toda la arquitectura

La grandeza de este primitivo no está en lo que añade sino en lo que quita. createResource te obligaba a sostener en la cabeza tres verdades a la vez —el dato, si estaba cargando, si había fallado— y a hilarlas por toda la interfaz con guardias defensivos. createAsync colapsa esas tres en una sola lectura que se comporta como si el valor siempre estuviera presente, y traslada la carga y el error a fronteras declaradas una vez, arriba, con Suspense y ErrorBoundary. El truco conceptual es que una promesa se disfraza de signal: por fuera se lee llamándola, participa del grafo, rastrea dependencias como un memo y se recalcula cuando una fuente cambia; por dentro coordina con la infraestructura de suspensión para que el instante en que el dato no existe nunca aflore al código que lo consume. Esa ficción —tratar lo asíncrono como si fuera síncrono y dejar que el sistema gestione la espera— no es azúcar sintáctico: es el modelo que Solid 2.0 lleva al corazón del sistema reactivo, donde cualquier derivación podrá ser asíncrona y lo pendiente fluirá por el grafo como un estado de primera clase. Aprender createAsync hoy no es aprender una función de una librería de rutas; es adoptar por adelantado la forma en que se escribirán los datos en Solid durante la próxima década. Y la única disciplina que exige a cambio es respetar la frontera del await: leer las dependencias antes de suspender, porque el rastreo síncrono termina donde empieza la promesa. Quien interioriza esa regla escribe datos asíncronos con la misma naturalidad con que escribe un memo, y deja de coordinar estados para empezar a declarar derivaciones.

⚔️ Convierte una promesa en un signal
  1. Crea un getUsuario con query y léelo desde un createAsync; pinta el nombre dentro de un Suspense y confirma que nunca escribes usuario()?.nombre con interrogante.
  2. Cambia la prop id desde un botón y verifica que la derivación se vuelve a disparar y el fallback reaparece durante la recarga.
  3. Introduce a propósito el bug del await: lee props.id después de un await y comprueba que cambiar el id ya no recarga; arréglalo subiendo la lectura.
  4. Añade initialValue a una segunda derivación y observa que ya no suspende en el primer render.
  5. Envuelve la lectura en un ErrorBoundary, haz que la promesa rechace y confirma que el error viaja a la frontera sin un solo try/catch en el componente.