wandres.dev
DATA LOADING · query, cache, createAsync

query y createAsync: el patrón canónico de carga

La forma canónica de cargar datos en SolidStart es un par: `query` envuelve el fetcher y le da una identidad memoizada por clave, y `createAsync` lo lee de forma reactiva integrándose con `Suspense`. Cómo colocar las queries en un módulo de datos, por qué el fetcher suele cruzar a servidor con `use server`, cómo la clave se compone del nombre y los argumentos serializados, qué exponen `keyFor` y `key`, y por qué el alcance de la caché es una petición en el servidor y una pestaña en el cliente.

⏱ 16 min

Cargar datos en una aplicación real no es lanzar un fetch y pintar el resultado: es darle a cada dato una identidad estable que toda la app comparte, que no se pide dos veces, y que sobrevive a las navegaciones. SolidStart resuelve esto con un par que conviene grabar como una sola unidad. query toma tu fetcher y lo convierte en una función memoizada por clave; createAsync lee esa query dentro del grafo reactivo y la enlaza con Suspense sin fontanería. Este par es el patrón canónico —el ladrillo del que dependen preload, la revalidación y el streaming— y todo el nivel se construye sobre él.

🎯 Al terminar esta lección sabrás
  • Envolver un fetcher en query con un nombre único y colocarlo en un módulo de datos.
  • Leer la query de forma reactiva con createAsync e integrarla con Suspense.
  • Entender cómo la clave se compone del nombre y de los argumentos serializados, y qué exponen keyFor y key.
  • Reconocer el alcance de la caché: una petición en el servidor, una pestaña en el cliente.

El fetcher desnudo y sus tres grietas

Un componente que pide su propio dato con fetch parece inocente, pero arrastra tres problemas que no se ven en la demo y sí en producción. No hay deduplicación: si tres componentes muestran el mismo producto, salen tres peticiones idénticas. No hay caché: al navegar fuera y volver, se repite el trabajo entero. Y encaja mal con la suspensión: coordinar el estado de carga a mano es tedioso y frágil.

// Ingenuo: cada componente reinventa la peticion, sin coordinacion ni cache
async function Ficha(props: { id: string }) {
  const res = await fetch(`/api/productos/${props.id}`);
  const producto = await res.json();
  return <h1>{producto.nombre}</h1>;
}

El diagnóstico de fondo no es de rendimiento: es que “el producto 7” debería ser una sola cosa que la app referencia, no una petición que cada consumidor vuelve a inventar. La cura es dar a cada dato una clave estable y una única fuente memoizada.

query: un fetcher con identidad

query recibe tu función asíncrona y un nombre único, y devuelve otra función con la misma firma pero memoizada por clave. Se coloca en un módulo de datos —lejos de los componentes— para que toda la app importe la misma instancia. El fetcher suele cruzar a servidor con la directiva use server, de modo que la consulta a la base de datos nunca viaja al navegador.

// ~/lib/productos.ts
import { query } from "@solidjs/router";

export const getProducto = query(async (id: string) => {
  "use server";
  const producto = await db.producto.findUnique({ where: { id } });
  if (!producto) throw new Error("producto no encontrado");
  return producto;
}, "producto");

export const getProductos = query(async () => {
  "use server";
  return db.producto.findMany({ orderBy: { nombre: "asc" } });
}, "productos");

El segundo argumento —"producto", "productos"— es el nombre base de la query y debe ser único en toda la app: es la mitad de su clave. Llamar a getProducto("7") desde varios sitios a la vez no dispara varias peticiones: la primera arranca la red y las demás reciben la misma promesa en vuelo; cuando resuelve, el valor queda cacheado bajo esa clave.

createAsync: el puente reactivo

query por sí sola no es reactiva. El puente al grafo es createAsync: le pasas una función que llama a la query y te devuelve un accessor que se suspende mientras carga e integra con Suspense sin una línea extra.

import { createAsync } from "@solidjs/router";
import { Suspense } from "solid-js";
import { getProducto } from "~/lib/productos";

function Ficha(props: { id: string }) {
  const producto = createAsync(() => getProducto(props.id));
  return (
    <Suspense fallback={<Esqueleto />}>
      <h1>{producto()?.nombre}</h1>
      <p>{producto()?.precio}</p>
    </Suspense>
  );
}

Como createAsync rastrea props.id, cuando el id cambia se relee la query con la clave nueva; si ese valor ya estaba cacheado, aparece sin parpadeo. El accessor vale undefined hasta que la primera carga resuelve —de ahí el ?. al leerlo—; si prefieres eliminar ese hueco, pasa un initialValue en las opciones, y bajo transiciones el accessor conserva el valor anterior mientras el nuevo carga en vez de volver a undefined.

Cuando una pantalla necesita varios datos, la regla de oro es no encadenarlos: declara cada createAsync por separado para que sus queries salgan en paralelo, en lugar de esperar una para lanzar la siguiente y encadenar un waterfall. Solo cuando un dato depende de verdad del anterior —su argumento sale de la primera respuesta— se justifica leerlo dentro del segundo accessor. Y como el accessor es un signal más, puedes derivarlo con un createMemo, pasarlo por props o leerlo en otro efecto: toda la maquinaria de grano fino se le aplica igual.

// Paralelo: ambas queries salen a la vez, sin cascada
const producto = createAsync(() => getProducto(props.id));
const resenas = createAsync(() => getResenas(props.id));

// Dependiente: solo si el segundo necesita el resultado del primero
const relacionados = createAsync(async () => {
  const p = await getProducto(props.id);
  return getRelacionados(p.categoria);
});
flowchart LR
A[componente lee getProducto 7] --> Q[query producto clave 7]
B[otro componente lee getProducto 7] --> Q
Q -->|una sola vez| R[peticion a servidor]
R --> V[valor cacheado bajo la clave]
V --> A
V --> B
V -->|createAsync suspende| S[Suspense pinta fallback y luego dato]
style Q fill:#89b4fa,color:#11111b
style R fill:#f9e2af,color:#11111b
style V fill:#a6e3a1,color:#11111b

La clave y el alcance de la caché

La clave que memoiza cada entrada se compone del nombre que diste y de los argumentos serializados. Dos utilidades exponen esa clave para operar sobre la caché más adelante: getProducto.keyFor("7") devuelve la clave de una instancia concreta, y getProducto.key devuelve el nombre base que agrupa a todas sus instancias. Las usarás sin falta al revalidar.

getProducto.keyFor("7"); // clave de un producto concreto
getProducto.key;         // nombre base: agrupa todos los productos

El detalle que separa a Solid de una caché casera es el alcance. En el cliente, la caché vive lo que la pestaña, así que navegar y volver reutiliza el dato. En el servidor —bajo SSR— la caché está atada a la petición actual: nace al entrar la solicitud y muere con su respuesta, lo que impide que el dato de un usuario se filtre en la respuesta de otro. Ese doble alcance es también lo que permite la serialización transparente: cuando el servidor resuelve una query durante el SSR, su valor viaja en el HTML y rehidrata la caché del cliente, de modo que el primer render en el navegador no repite la petición. La query es, en ese sentido, un canal que cruza la frontera red —se llena en el servidor y se lee en el cliente sin una segunda ida al backend—, y esa continuidad entre entornos es la base sobre la que se apoyan la precarga y el streaming que verás a continuación.

🧩

query envuelve

Toma el fetcher y un nombre unico y devuelve una funcion memoizada por clave. Vive en un modulo de datos, no en el componente.

🔌

createAsync lee

Puente reactivo: rastrea sus argumentos, suspende mientras carga e integra con Suspense sin fontaneria.

🔑

clave e identidad

Nombre mas argumentos serializados. keyFor apunta a una instancia, key al grupo entero.

💡
El nombre de la query es una clave global: trátalo como tal

El segundo argumento de query no es una etiqueta de depuración: es la mitad de la clave con la que el framework indexa la caché y, más tarde, decide qué revalidar. Si dos queries distintas comparten nombre, sus cachés colisionan y una servirá datos de la otra. Elige nombres estables y únicos —"producto", "productos", "resenas"— y trátalos como el espacio de nombres de tu capa de datos, no como cadenas desechables.

query da identidad al dato; createAsync la observa. Todo lo demás cuelga de aquí

La tentación es leer este par como “una caché para no repetir fetch más un hook que suspende”, y quedarse en la comodidad. Pero el cambio real es conceptual: query convierte cada dato en una entidad con identidad estable. “El producto 7” deja de ser un evento —una petición que alguien lanzó en algún componente— y pasa a ser un valor con nombre que toda la app comparte, referencia y observa a través de una única clave, mientras createAsync es el único órgano que sabe leer esa identidad dentro del grafo y traducir su promesa en suspensión. Esa inversión es la que hace posible el resto del nivel. La precarga que verás en la próxima lección no es más que resolver esa identidad antes de necesitarla; la deduplicación es que dos referencias a la misma identidad no producen dos peticiones; la revalidación es declarar que una identidad concreta quedó obsoleta y debe recomputarse; el streaming es decidir qué identidades bloquean el HTML y cuáles llegan después. Ninguno de esos patrones tendría un asidero si el dato siguiera siendo una llamada anónima enterrada en un componente. Y el alcance —una petición en el servidor, una pestaña en el cliente— no es un detalle de implementación sino la definición de hasta dónde esa identidad es única. Cuando dejas de pensar en peticiones y empiezas a pensar en identidades con clave y alcance, la carga de datos deja de ser coordinación manual y se vuelve un grafo de valores con nombre que el framework mantiene coherente por ti. Aprende este par como una sola pieza: es el sustantivo y el verbo de toda la gramática que sigue.

⚔️ Convierte tres fetches en una identidad compartida
  1. Escribe getProducto y getProductos con query, cada una con su nombre único, en un módulo ~/lib/productos.ts.
  2. Lee getProducto con createAsync dentro de un Suspense desde tres componentes con el mismo id y confirma en la pestaña de red que sale una sola petición.
  3. Cambia el id de forma reactiva y observa que una clave ya cacheada se pinta sin parpadeo mientras una nueva dispara red.
  4. Imprime getProducto.keyFor(id) y getProducto.key y explica qué agrupa cada una y para qué te servirán al revalidar.
  5. Razona por qué, bajo SSR, dos peticiones simultáneas de productos distintos no comparten la misma entrada de caché aunque el módulo sea el mismo.