wandres.dev
TAXONOMÍA DEL ESTADO · local, global, servidor, URL

Estado del servidor: es una cache

Los datos remotos no son estado propio: son una copia local de una verdad que vive en otra parte. Por qué el estado del servidor es una cache con política de revalidación, el anti-patrón de meterlo en el store global, y las herramientas de 2026 (TanStack Query, RSC, use).

⏱ 16 min

Aquí está el error conceptual que más código estropea en 2026: tratar los datos del servidor como si fueran estado de tu aplicación. No lo son. Un perfil de usuario, una lista de productos, el resultado de una búsqueda — todo eso vive en una base de datos que tú no controlas desde el cliente. Lo que tienes en el navegador no es el dato: es una copia, tomada en un instante, que empieza a envejecer en cuanto la recibes. El estado del servidor es una cache. Y cachear, como bien dice el chiste, es uno de los dos problemas difíciles de la informática.

🎯 Al terminar esta lección sabrás
  • Entender por qué los datos remotos son una cache, no estado propio.
  • Nombrar las propiedades que lo distinguen: obsolescencia, deduplicación, revalidación.
  • Reconocer el anti-patrón de guardar datos del servidor en el store global.
  • Ubicar las herramientas de 2026: TanStack Query, Server Components y el hook use.

No es tu estado: es una copia de una verdad ajena

La fuente de la verdad de un producto no está en tu componente: está en el servidor. Tú solo tienes un reflejo. Ese reflejo tiene una propiedad que el estado local y el global de cliente no tienen: puede quedar obsoleto sin que tú hagas nada, porque otro usuario, otro proceso o el propio backend cambió el original mientras tu copia dormía en memoria.

Esto lo cambia todo. El estado de cliente (local o global) es la verdad: cuando lo escribes, es cierto por definición. El estado del servidor es una hipótesis sobre algo que vive fuera de tu alcance. Por eso las preguntas que te haces son distintas: no “¿cuál es el valor?”, sino “¿cuándo lo pedí?”, “¿sigue siendo válido?”, “¿debería volver a preguntar?”. Esas son preguntas de cache, no de estado.

La distinción tiene una raíz de propiedad. Del estado de cliente eres el dueño: nadie más puede cambiarlo a tus espaldas, así que basta con guardarlo. Del estado del servidor eres solo un lector: el original puede mutar en cualquier momento por causas que no controlas ni observas, y por eso guardarlo sin más es una trampa. Necesitas, además del dato, saber cuándo dejará de ser de fiar. Ese “además” es toda la diferencia entre un store y una cache.

flowchart LR
SV[servidor: fuente de la verdad] -->|fetch| C[cache del cliente]
C -->|render inmediato| V[vista]
C -->|marca stale tras un tiempo| C
V -->|refoco o remontaje| RV[revalidar en background]
RV -->|nuevo fetch| SV
style SV fill:#f38ba8,color:#11111b
style C fill:#fab387,color:#11111b
style V fill:#a6e3a1,color:#11111b

Las propiedades que lo hacen una clase aparte

Tratar el servidor como una cache trae de golpe una lista de necesidades que el estado local jamás plantea: obsolescencia (marcar un dato como viejo tras un tiempo, staleTime), revalidación (volver a pedirlo al reenfocar la ventana o reconectar), deduplicación (dos componentes que piden el mismo recurso disparan una sola petición), caché por clave, reintentos, paginación y estados de carga y error de primera clase. Reimplementar todo eso a mano, con useEffect y useState, es reescribir mal una librería que ya existe.

import { useQuery } from '@tanstack/react-query';

function Perfil({ id }: { id: string }) {
  const { data, isPending, isError } = useQuery({
    queryKey: ['usuario', id],   // la clave de cache
    queryFn: () => fetch(`/api/usuario/${id}`).then((r) => r.json()),
    staleTime: 60_000,           // fresco 1 min; luego revalida en segundo plano
  });
  // isPending y isError vienen dados: no los gestionas tu
}

La queryKey es la identidad del dato en la cache: dos componentes con la misma clave comparten una entrada y una sola petición. staleTime codifica tu política de frescura. Nada de esto es estado de aplicación: es gestión de cache, declarada.

Obsolescencia

Toda copia envejece. staleTime decide cuánto confías en ella antes de considerarla vieja y candidata a refrescarse.

🔁

Revalidación

Al reenfocar la ventana, reconectar o pasado un intervalo, la cache vuelve a pedir el dato en segundo plano sin bloquear la UI.

🧬

Deduplicación

Diez componentes que piden la misma queryKey disparan una sola petición y comparten el resultado. La clave es la identidad.

🛟

Reintentos y errores

Reintento con retroceso exponencial ante fallos de red, y estados isPending e isError de primera clase, sin un solo useEffect.

Dos parámetros que se confunden a diario: staleTime es cuánto tiempo una copia se considera fresca (y no se revalida); gcTime es cuánto sobrevive en memoria una entrada sin observadores antes de recolectarse. Frescura y retención son ejes distintos: un dato puede estar obsoleto pero seguir en cache, listo para mostrarse al instante mientras se revalida por detrás. Entender esa separación es la diferencia entre configurar una cache y pelearse con ella.

⚠️
El anti-patrón: datos del servidor en el store global

Durante años el reflejo por defecto fue: “llega un dato del servidor, lo guardo en Redux”. Es el anti-patrón que definió la era. Cuando metes datos remotos en tu store de cliente, te conviertes en responsable de todo lo que una cache hace por ti: cuándo caducan, cómo se revalidan, cómo se deduplican, cómo se invalidan tras una mutación. Acabas escribiendo cientos de líneas de reducers, thunks y acciones que no son más que una cache casera, peor y con bugs. La medición del ecosistema lo confirma: buena parte de la caída de Redux entre 2020 y 2026 se explica porque TanStack Query se llevó justo esa responsabilidad, dejando al store solo el estado de cliente genuino.

El servidor como estado en 2026: RSC y use

La frontera se ha movido. Con los React Server Components y el hook use, buena parte del estado del servidor ni siquiera cruza al cliente como estado: se resuelve en el servidor y llega ya renderizado, o se pasa como promesa que el cliente consume con Suspense.

import { use, Suspense } from 'react';

function Perfil({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise); // suspende hasta resolver
  return <h1>{user.nombre}</h1>;
}

// el padre pasa la promesa y delega la espera a Suspense
<Suspense fallback={<Spinner />}>
  <Perfil userPromise={fetchUser(id)} />
</Suspense>

Este modelo trae una técnica clave: el prefetch. El servidor —o un loader de ruta— pide los datos antes de renderizar y entrega al cliente la cache ya rellena, que hidrata sin un segundo viaje ni un spinner. Es la cache de servidor cruzando la frontera con el dato ya dentro, y elimina las cascadas de peticiones tan típicas del fetching hecho a mano en useEffect.

Frameworks como Next.js y TanStack Start empujan el estado del servidor hacia el propio servidor, y usan TanStack Query o SWR para la parte que sí es interactiva en cliente (listas que se refrescan, mutaciones optimistas, scroll infinito). El modelo mental no cambia: sigue siendo una cache de una verdad remota. Solo se mueve dónde vive la cache.

📝
TanStack Query o SWR

Las dos librerías resuelven el mismo problema con filosofías cercanas. SWR (de Vercel) es más pequeña y minimalista; lleva la estrategia stale-while-revalidate hasta en el nombre. TanStack Query es más completa: mutaciones, caché infinita, invalidación selectiva y herramientas de depuración. La regla práctica de 2026: SWR para lecturas simples, TanStack Query cuando hay mutaciones y sincronización serias. Ninguna es un gestor de estado de cliente: ambas son, explícitamente, cachés.

La mutación: escribir y reconciliar

Leer es la mitad fácil. La difícil es escribir: al enviar un POST o un PATCH, tu copia local queda desactualizada al instante, porque acabas de cambiar la verdad remota. Ahí entra la invalidación, la otra cara de la cache. Tras una mutación exitosa marcas las claves afectadas como obsoletas y la librería las revalida sola:

import { useMutation, useQueryClient } from '@tanstack/react-query';

function useRenombrar(id: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (nombre: string) => api.patch(`/usuario/${id}`, { nombre }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['usuario', id] }),
  });
}

Un paso más allá está la actualización optimista (Nivel 35): pintar el resultado esperado antes de que el servidor confirme, y revertir si falla. La cache pasa a tener, por un instante, un valor que el servidor aún no ha ratificado — una apuesta que luego se reconcilia o se deshace. Es el reconocimiento explícito de que tu cache y la verdad remota son dos cosas que hay que reconciliar, no una sola que se pueda escribir sin más. Toda esta maquinaria —invalidar, revalidar, reconciliar— es justo lo que reescribirías a mano, y peor, si metieras el servidor en el store.

La verdad prestada exige una política, no un valor

El salto de madurez ocurre el día en que dejas de preguntar “¿qué valor tiene este dato?” y empiezas a preguntar “¿qué política rige esta copia?”. El estado del servidor es verdad prestada: la tienes un rato, envejece y hay que devolverla al día. Gobernarlo es responder cuatro preguntas de política, no de valor. Frescura: ¿cuánto tiempo confío en esta copia sin volver a preguntar? Revalidación: ¿qué evento me obliga a refrescar — el foco de la ventana, un intervalo, una reconexión? Invalidación: cuando yo mismo muto el dato con un POST, ¿qué entradas de cache quedan mentirosas y hay que tirar? Y consistencia: mientras espero la confirmación del servidor, ¿muestro el valor viejo, un spinner, o apuesto por el resultado y lo revierto si falla (la actualización optimista del Nivel 35)? Ninguna de esas preguntas tiene sentido para el estado local, porque el estado local es dueño de su verdad. El estado del servidor nunca lo es. Por eso mezclarlo con el estado de cliente en el mismo cajón es el error categórico de la disciplina: obligas a una cache a fingir que es una fuente de la verdad, y pagas ese engaño en cada dato obsoleto que un usuario ve en pantalla. Separa las dos clases y la mitad de tus bugs de sincronización desaparecen, porque dejas de gestionar a mano lo que una cache gestiona por diseño.

⚔️ Saca el servidor de tu store
  1. Localiza en tu store global todo lo que en realidad vino de una petición HTTP: es candidato a salir.
  2. Migra una de esas piezas a TanStack Query con su queryKey y un staleTime razonable.
  3. Elimina el useEffect + useState que hacías a mano para cargar datos y compara cuántas líneas desaparecen.
  4. Prueba una mutación con invalidación (invalidateQueries) y observa cómo la cache se revalida sola tras escribir.
  5. Define para un dato su política de frescura: ¿qué staleTime tiene sentido y qué evento debería forzar su revalidación?