1. Introducción contextual
En los últimos cinco años la velocidad de carga se ha convertido en un factor decisivo para la retención de usuarios y el posicionamiento SEO. Según el Web Almanac 2023 de HTTP Archive, el 70 % de los visitantes abandona una página si tarda más de 3 s en mostrarse. En entornos React, donde la mayor parte del contenido se genera en el cliente, la latencia de las peticiones a APIs externas suele ser el cuello de botella principal.
El caching es la técnica que permite reutilizar datos ya obtenidos, reduciendo la cantidad de round‑trips y, por ende, mejorando métricas como First Contentful Paint (FCP) y Time to Interactive (TTI). Dentro del ecosistema React, la librería SWR (Stale‑While‑Revalidate) de Vercel ha ganado terreno porque combina un modelo declarativo de fetching con un motor de cache inteligente que respeta los principios del HTTP caching.
Resolver el problema de caching hoy es crítico por tres motivos:
- Experiencia de usuario – Cada milisegundo cuenta para la percepción de fluidez.
- Costos operacionales – Menos peticiones al backend reducen el consumo de recursos en la nube.
- Escalabilidad – Un buen diseño de cache permite que la aplicación mantenga su rendimiento bajo alta concurrencia sin necesidad de escalar horizontalmente el API.
En este artículo profundizaremos en estrategias de caching específicas para React con SWR, cubriendo desde los fundamentos teóricos hasta ejemplos de código listos para producción, casos de negocio y anti‑patrones que hay que evitar.
2. Fundamentos y arquitectura
2.1. Principios de caching en la web
| Principio | Descripción |
|---|---|
| Cache‑Aside | La aplicación controla cuándo leer/escribir la cache. Ideal para datos que cambian con poca frecuencia. |
| Write‑Through | Cada escritura actualiza simultáneamente la cache y el origen de datos. Garantiza consistencia a costa de latencia. |
| Stale‑While‑Revalidate | Sirve datos “stale” mientras se actualizan en background. Balancea frescura y velocidad (modelo que implementa SWR). |
2.2. ¿Por qué SWR?
SWR se basa en el algoritmo Stale‑While‑Revalidate de HTTP/1.1. Cada llamada a useSWR(key, fetcher) devuelve:
- Datos en caché (si existen).
- Estado de revalidación (si se está actualizando).
- Errores (si el fetch falló).
El motor interno mantiene un mapa global de claves → valores y controla la expiración mediante revalidation intervals, focus revalidation y revalidation on reconnect. Además, permite mutations (actualizaciones locales) y revalidations manuales, lo que lo hace apto para aplicaciones con alta interactividad (e‑commerce, dashboards, SaaS).
2.3. Diagrama conceptual (descrito en texto)
- Componente React llama a
useSWR(key, fetcher). - SWR Cache Layer busca
keyen el global store (Map).- Si existe → devuelve datos inmediatamente.
- Si no → ejecuta
fetcher.
- Fetcher (función async) hace la petición HTTP (axios/fetch).
- Respuesta se guarda en la cache con metadata (timestamp, ttl).
- Revalidación se dispara bajo:
- focus (ventana del navegador recupera foco).
- interval (opcional, cada N ms).
- mutate (actualización manual).
- Componentes suscritos reciben los datos actualizados vía React state.
Este flujo permite que todos los componentes que compartan la misma clave lean de la misma caché, evitando peticiones duplicadas.
2.4. Decisiones de diseño y trade‑offs
| Decisión | Ventaja | Desventaja |
|---|---|---|
| TTL estático (ej. 5 min) | Simplicidad, predictibilidad | Posible desactualización de datos críticos |
| TTL dinámico basado en encabezados | Frescura alineada a la política del backend | Complejidad de parseo y mayor código |
| Cache global (SWR) vs. cache local (React context) | Compartición automática entre componentes | Posible contaminación de estado si no se segmenta bien |
| Persistencia en IndexedDB | Cache survive page reloads | Overhead de serialización y posible bloqueo de UI |
3. Implementación práctica
A continuación se presentan cinco ejemplos que muestran la evolución de una solución de fetching básica a una arquitectura robusta con SWR, manejo de errores, revalidación inteligente y persistencia.
3.1. Enfoque naïve: fetch dentro del componente
// src/components/ProductListNaive.tsx
import React, { useEffect, useState } from 'react';
import axios from 'axios';
interface Product {
id: number;
name: string;
price: number;
}
/**
* Ejemplo básico: fetch en useEffect, sin caching.
* Cada renderizado del componente vuelve a disparar la petición.
*/
export const ProductListNaive: React.FC = () => {
const [products, setProducts] = useState<Product[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
const fetchProducts = async () => {
try {
const response = await axios.get<Product[]>('/api/products');
setProducts(response.data);
} catch (err) {
if (axios.isAxiosError(err) && err.response) {
setError(`Error ${err.response.status}: ${err.response.statusText}`);
} else {
setError('Error inesperado al cargar productos');
}
} finally {
setLoading(false);
}
};
fetchProducts();
}, []); // Dependencia vacía, pero se ejecuta cada montaje
if (loading) return <p>Cargando productos...</p>;
if (error) return <p className="error">{error}</p>;
return (
<ul>
{products.map(p => (
<li key={p.id}>
{p.name} - ${p.price.toFixed(2)}
</li>
))}
</ul>
);
};
Problemas detectados:
- Petición duplicada si el componente se desmonta y vuelve a montar.
- No hay caché ni revalidación.
- Manejo de errores rudimentario.
3.2. Primera capa de caching: SWR con TTL fija
// src/hooks/useProducts.ts
import useSWR, { SWRConfiguration } from 'swr';
import axios, { AxiosResponse } from 'axios';
export interface Product {
id: number;
name: string;
price: number;
}
/**
* Fetcher tipado que devuelve la data directamente.
* SWR lo usará para almacenar en su caché global.
*/
const fetcher = async (url: string): Promise<Product[]> => {
const response: AxiosResponse<Product[]> = await axios.get(url);
return response.data;
};
/**
* Hook reutilizable con TTL de 5 minutos.
*/
export const useProducts = (config?: SWRConfiguration) => {
const { data, error, mutate, isValidating } = useSWR<Product[]>(
'/api/products',
fetcher,
{
// 5 minutos de stale time → datos servidos sin revalidar inmediatamente
staleTime: 5 * 60 * 1000,
// Revalidar en foco para mantener frescura
revalidateOnFocus: true,
// Opcional: revalidar cada 10 minutos en background
refreshInterval: 10 * 60 * 1000,
...config,
}
);
return {
products: data,
isLoading: !error && !data,
isError: error,
mutate,
isValidating,
};
};
// src/components/ProductListSWR.tsx
import React from 'react';
import { useProducts } from '../hooks/useProducts';
export const ProductListSWR: React.FC = () => {
const { products, isLoading, isError } = useProducts();
if (isLoading) return <p>Cargando productos...</p>;
if (isError) return <p className="error">No se pudieron cargar los productos.</p>;
return (
<ul>
{products!.map(p => (
<li key={p.id}>
{p.name} - ${p.price.toFixed(2)}
</li>
))}
</ul>
);
};
Ventajas respecto al naïve:
- Cache global evita peticiones repetidas entre componentes.
- TTL controla la frescura.
- Revalidación automática al volver a enfocar la ventana.
3.3. Cache con deduplication y error retry
// src/hooks/useProductsAdvanced.ts
import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
import axios, { AxiosError, AxiosResponse } from 'axios';
import { Product } from './useProducts';
const fetcher = async (url: string): Promise<Product[]> => {
const response: AxiosResponse<Product[]> = await axios.get(url, {
// Timeout para evitar bloqueos prolongados
timeout: 8000,
});
return response.data;
};
/**
* Configuración avanzada:
* - dedupingInterval: 2 s para evitar peticiones simultáneas.
* - retryOnError: 3 intentos con backoff exponencial.
* - onErrorRetry: lógica personalizada para no reintentar 404.
*/
export const useProductsAdvanced = (
config?: SWRConfiguration
): SWRResponse<Product[], AxiosError> => {
const baseConfig: SWRConfiguration = {
staleTime: 2 * 60 * 1000,
revalidateOnFocus: true,
dedupingInterval: 2000,
errorRetryCount: 3,
errorRetryInterval: 1500,
onErrorRetry: (err, key, config, revalidate, opts) => {
// No reintentar si el error es 404 (recurso no encontrado)
if (err?.response?.status === 404) return;
// Reintentar con backoff exponencial
setTimeout(() => revalidate({ retryCount: opts.retryCount + 1 }), opts.retryCount * 1500);
},
...config,
};
return useSWR<Product[], AxiosError>('/api/products', fetcher, baseConfig);
};
// src/components/ProductListAdvanced.tsx
import React from 'react';
import { useProductsAdvanced } from '../hooks/useProductsAdvanced';
import { Product } from '../hooks/useProducts';
export const ProductListAdvanced: React.FC = () => {
const { data, error, isValidating, mutate } = useProductsAdvanced();
if (!data && !error) return <p>Cargando productos...</p>;
if (error) {
const status = error.response?.status;
const msg = status === 404 ? 'Producto no encontrado' : 'Error al cargar datos';
return <p className="error">{msg}</p>;
}
const handleRefresh = () => mutate(); // Fuerza revalidación manual
return (
<>
<button onClick={handleRefresh} disabled={isValidating}>
{isValidating ? 'Actualizando…' : 'Actualizar lista'}
</button>
<ul>
{data!.map((p: Product) => (
<li key={p.id}>
{p.name} - ${p.price.toFixed(2)}
</li>
))}
</ul>
</>
);
};
Mejoras clave:
- Deduplication evita múltiples requests idénticos en ventana de 2 s.
- Retry con backoff mejora resiliencia ante fallos transitorios.
mutatemanual permite invalidar la cache bajo eventos de negocio (ej. creación de producto).
3.4. Persistencia en IndexedDB con swr + swr-async
Para aplicaciones que requieren offline‑first o que quieren mantener datos entre recargas, podemos extender SWR con una capa de persistencia. Usaremos la librería idb-keyval para interactuar con IndexedDB.
// src/lib/cache/persistedCache.ts
import { Cache } from 'swr';
import { get, set } from 'idb-keyval';
/**
* Implementación de la interfaz Cache de SWR que persiste
* cada clave en IndexedDB. Se usa JSON.stringify para serializar.
*/
export class IndexedDBCache implements Cache {
async get(key: string) {
const raw = await get<string>(key);
return raw ? JSON.parse(raw) : undefined;
}
async set(key: string, value: any) {
await set(key, JSON.stringify(value));
}
async delete(key: string) {
await set(key, undefined);
}
}
// src/hooks/useProductsPersisted.ts
import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
import axios, { AxiosResponse } from 'axios';
import { IndexedDBCache } from '../lib/cache/persistedCache';
import { Product } from './useProducts';
const fetcher = async (url: string): Promise<Product[]> => {
const resp: AxiosResponse<Product[]> = await axios.get(url);
return resp.data;
};
const persistedCache = new IndexedDBCache();
export const useProductsPersisted = (
config?: SWRConfiguration
): SWRResponse<Product[], any> => {
const baseConfig: SWRConfiguration = {
cache: persistedCache,
// Si hay datos en IndexedDB, los usamos inmediatamente
fallbackData: undefined,
// Revalidar siempre al montar, pero sin bloquear UI
revalidateOnMount: true,
// TTL de 10 min para datos persistidos
staleTime: 10 * 60 * 1000,
...config,
};
return useSWR<Product[]>('/api/products', fetcher, baseConfig);
};
// src/components/ProductListPersisted.tsx
import React from 'react';
import { useProductsPersisted } from '../hooks/useProductsPersisted';
export const ProductListPersisted: React.FC = () => {
const { data, error, isValidating, mutate } = useProductsPersisted();
if (!data && !error) return <p>Cargando productos (offline fallback)…</p>;
if (error) return <p className="error">Fallo al cargar datos.</p>;
return (
<>
<button onClick={() => mutate()} disabled={isValidating}>
{isValidating ? 'Sincronizando…' : 'Sincronizar'}
</button>
<ul>
{data!.map(p => (
<li key={p.id}>
{p.name} - ${p.price.toFixed(2)}
</li>
))}
</ul>
</>
);
};
Razonamiento:
- Persistencia permite que la UI muestre datos incluso sin conexión, mejorando Core Web Vitals (First Input Delay).
- Cache interface de SWR es extensible; al implementar
IndexedDBCachemantenemos la API original sin cambios en los componentes.
3.5. Cache distribuida con SWR + Supabase Edge Functions
En escenarios empresariales con alta concurrencia, es útil que la capa de cache sea compartida entre instancias (por ejemplo, usando Redis). Supabase permite crear Edge Functions que actúan como un proxy inteligente con cache en Redis.
// supabase/functions/cacheProducts.ts (Node.js + TypeScript)
import { serve } from 'std/server';
import { createClient } from '@supabase/supabase-js';
import Redis from 'ioredis';
const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!);
const redis = new Redis({
host: Deno.env.get('REDIS_HOST'),
port: Number(Deno.env.get('REDIS_PORT')),
password: Deno.env.get('REDIS_PASSWORD'),
});
serve(async (req) => {
const cacheKey = 'products:all';
// Intentamos leer de Redis primero
const cached = await redis.get(cacheKey);
if (cached) {
