Saltar al contenido

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.

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.

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

TSX
import { query, createAsync } from "@solidjs/router";// query dedupe y cachea por clave; se integra con preload y revalidacionconst 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.

TSX
// MAL: props.id se lee despues del await y NO queda rastreadoconst datos = createAsync(async () => {  await precalentar();  return getUsuario(props.id); // esta lectura no crea dependencia});// BIEN: captura la dependencia de forma sincrona, antes del primer awaitconst 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á.

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.

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

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

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