wandres.dev
PATRONES DE DATA FETCHING · cache, dedup, prefetch

Deduplicación y caché con query

El patrón fundacional del data fetching en SolidStart: envolver un fetcher en `query` para que el framework lo memoice por clave. Cómo `query` combina el dedup de llamadas concurrentes con la caché del último valor, cómo `createAsync` lee esa query de forma reactiva e integra con Suspense, cómo se deriva la clave a partir del nombre y los argumentos serializados, y por qué el alcance de la caché es una sola petición en el servidor y la sesión de la pestaña en el cliente.

⏱ 16 min

El primer patrón de datos que hay que dominar no resuelve cómo pedir, sino cómo no pedir dos veces lo mismo. En una app real, el mismo dato lo necesitan la cabecera, la barra lateral y el cuerpo; y al navegar de una ruta a otra y volver, se vuelve a pedir lo que ya tenías. La respuesta de SolidStart es query: envuelves tu fetcher una vez y el framework lo convierte en una función memoizada por clave que deduplica llamadas concurrentes y cachea el último valor. createAsync la lee de forma reactiva y la enlaza con Suspense. A partir de aquí, todos los patrones del nivel —prefetch, mutaciones, revalidación— se apoyan en esta pieza.

🎯 Al terminar esta lección sabrás
  • Envolver un fetcher en query para deduplicar y cachear la misma petición por clave.
  • Leer una query de forma reactiva con createAsync e integrarla con Suspense.
  • Entender cómo la clave se deriva del nombre y de los argumentos serializados.
  • Reconocer el alcance de la caché: una petición en el servidor, la sesión de la pestaña en el cliente.

El problema: la misma petición, muchas veces

Sin una capa de caché, cada componente que necesita un dato lanza su propio fetch. Si tres componentes muestran el mismo usuario, salen tres peticiones idénticas al mismo endpoint, en el mismo instante, por el mismo valor. Y peor: al navegar fuera y volver, el trabajo se repite entero, aunque el dato no haya cambiado.

// Ingenuo: cada Perfil dispara su propia peticion, sin coordinacion
async function Perfil(props: { id: string }) {
  const res = await fetch(`/api/usuarios/${props.id}`);
  const usuario = await res.json();
  return <h1>{usuario.nombre}</h1>;
}
// Tres <Perfil id="7" /> = tres peticiones identicas

El problema no es de rendimiento sino de identidad de los datos: “el usuario 7” debería ser una sola cosa compartida por toda la app, no una petición que cada consumidor reinventa. La solución es dar a cada dato una clave estable y una única fuente memoizada.

query: memoizar un fetcher por clave

query toma tu función asíncrona y un nombre único, y devuelve una función con las mismas firmas pero memoizada. Todas las llamadas con los mismos argumentos comparten una sola ejecución en vuelo y reutilizan el último valor resuelto.

// data/usuarios.ts
import { query } from "@solidjs/router";

export const getUsuario = query(async (id: string) => {
  const res = await fetch(`/api/usuarios/${id}`);
  if (!res.ok) throw new Error("usuario no encontrado");
  return (await res.json()) as Usuario;
}, "usuario");

El segundo argumento, "usuario", es el nombre base de la query y debe ser único en toda la app: es la mitad de su clave. La otra mitad son los argumentos. Llamar a getUsuario("7") desde tres sitios a la vez no dispara tres peticiones: la primera arranca la red y las otras dos reciben la misma promesa en vuelo. Cuando resuelve, el valor queda cacheado bajo esa clave, y una cuarta llamada posterior lo devuelve sin tocar la red.

createAsync lee la query de forma reactiva

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 de fontanería.

import { createAsync } from "@solidjs/router";
import { Suspense } from "solid-js";

function Perfil(props: { id: string }) {
  const usuario = createAsync(() => getUsuario(props.id));
  return (
    <Suspense fallback={<Cargando />}>
      <h1>{usuario()?.nombre}</h1>
    </Suspense>
  );
}

Como createAsync rastrea props.id, cuando el id cambia se vuelve a leer la query con la clave nueva; si ese valor ya está cacheado, aparece sin parpadeo. Y si diez componentes distintos hacen createAsync(() => getUsuario("7")), la deduplicación de query garantiza una sola petición para todos.

El accessor que devuelve createAsync vale undefined hasta que la primera carga resuelve —de ahí el ?. al leerlo fuera de un Suspense—. Si prefieres un valor de arranque para evitar ese hueco, pásalo en las opciones; y bajo transiciones el accessor conserva el valor anterior mientras el nuevo carga, en vez de volver a undefined, lo que evita parpadeos al recargar datos que ya tenías.

// initialValue elimina el undefined inicial; util para listas
const usuario = createAsync(() => getUsuario(props.id), {
  initialValue: undefined,
  deferStream: true, // en SSR, espera a este dato antes de emitir HTML
});
flowchart TD
A[Cabecera lee getUsuario 7] --> Q[query usuario clave 7]
B[Barra lateral lee getUsuario 7] --> Q
C[Cuerpo lee getUsuario 7] --> Q
Q -->|una sola vez| R[peticion de red]
R --> V[valor cacheado bajo la clave]
V --> A
V --> B
V --> C
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: getUsuario.keyFor("7") devuelve la clave de esa instancia concreta, y getUsuario.key devuelve el nombre base que agrupa a todas sus instancias. Las necesitarás en cuanto llegues a la revalidación.

getUsuario.keyFor("7"); // clave de un usuario concreto
getUsuario.key;         // nombre base: agrupa todos los usuarios

Ahora la parte que separa a Solid de una caché casera: el alcance. En el cliente, la caché vive lo que la pestaña, así que navegar y volver reutiliza el dato en vez de repedirlo. En el servidor —bajo SSR— la caché está atada a la petición actual: se crea al entrar la solicitud y muere con su respuesta. Ese aislamiento por petición es exactamente lo que evita que el dato de un usuario se filtre en la respuesta de otro, sin que tú tengas que pensarlo.

Ese doble alcance es también lo que hace posible la serialización transparente entre servidor y cliente. Cuando el servidor resuelve una query durante el SSR, su valor viaja en el HTML y se rehidrata en la caché del cliente, de modo que el primer render en el navegador no repite la petición que el servidor ya hizo. 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. Para escribir a esa caché sin pasar por la red —tras una mutación local, por ejemplo— la propia query expone utilidades, pero eso pertenece ya al patrón de revalidación.

🔑

Nombre + argumentos

La clave sale del nombre unico que pasas a query y de los argumentos serializados. Mismos argumentos, misma clave, misma entrada de cache.

🔗

Dedup en vuelo

Varias llamadas concurrentes con la misma clave comparten una promesa. La red se toca una vez aunque diez componentes pidan lo mismo.

🧭

Alcance segun entorno

En el cliente la cache dura lo que la pestana. En el servidor dura lo que la peticion, aislando datos entre usuarios.

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

El segundo argumento de query no es un comentario ni 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 —"usuario", "todos", "factura"— y trátalos como el espacio de nombres de tu capa de datos, no como cadenas desechables. El día que invalides getUsuario.key querrás estar seguro de que ese nombre identifica una sola cosa.

query no cachea peticiones: da identidad a los datos

La tentación es leer query como “una caché para no repetir fetch”, y quedarse en el ahorro de red. Pero el cambio real es conceptual: query convierte cada dato en una entidad con identidad estable. “El usuario 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. Esa inversión es la que hace posible todo lo demás en este nivel. El prefetch 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. 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: en el servidor, “el usuario 7” es único dentro de la petición que lo pidió, porque distintos usuarios no deben compartir la misma instancia; en el cliente, es único dentro de la sesión, porque una pestaña es un solo usuario. Cuando dejas de pensar en peticiones y empiezas a pensar en identidades con clave y alcance, el data fetching deja de ser un problema de coordinación manual y se vuelve un grafo de valores con nombre que el framework mantiene coherente por ti.

⚔️ Convierte tres fetches en una identidad compartida
  1. Escribe getUsuario con query y un nombre único; móntalo en tres componentes con el mismo id y confirma en la pestaña de red que sale una sola petición.
  2. Léelo con createAsync dentro de un Suspense y verifica que el fallback aparece solo durante la primera carga, no al reusar el valor cacheado.
  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 getUsuario.keyFor(id) y getUsuario.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 usuarios distintos no comparten la misma entrada de caché aunque el módulo sea el mismo.