Data Fetching con SWR - Estrategia de cache optima con React Hooks

Intermedio | 2025.12.02

Que es SWR

SWR (stale-while-revalidate) es una biblioteca de data fetching para React Hooks desarrollada por Vercel. Basada en la estrategia de invalidacion de cache HTTP, permite obtener datos de forma rapida y reactiva.

Principio de funcionamiento de SWR

1. Primera solicitud:

sequenceDiagram
    participant Client
    participant SWR Cache
    participant API Server
    Client->>SWR Cache: Solicitud
    SWR Cache->>API Server: fetch
    API Server-->>SWR Cache: Respuesta
    SWR Cache-->>Client: Retorno de datos

2. Re-solicitud (con cache):

sequenceDiagram
    participant Client
    participant SWR Cache
    participant API Server
    Client->>SWR Cache: Solicitud
    SWR Cache-->>Client: Retorno inmediato de datos antiguos
    SWR Cache->>API Server: Revalidacion en segundo plano
    API Server-->>SWR Cache: Nuevos datos
    SWR Cache-->>Client: Actualizacion con nuevos datos

Uso basico

Instalacion y configuracion

npm install swr
// lib/fetcher.ts
export const fetcher = async <T>(url: string): Promise<T> => {
  const res = await fetch(url);

  if (!res.ok) {
    const error = new Error('La solicitud API ha fallado');
    throw error;
  }

  return res.json();
};

// Fetcher con autenticacion
export const authFetcher = async <T>(url: string): Promise<T> => {
  const token = localStorage.getItem('token');

  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${token}`,
    },
  });

  if (!res.ok) {
    if (res.status === 401) {
      // Proceso de refresh del token, etc.
      throw new Error('Error de autenticacion');
    }
    throw new Error('La solicitud API ha fallado');
  }

  return res.json();
};

Hook useSWR

import useSWR from 'swr';
import { fetcher } from '@/lib/fetcher';

interface User {
  id: string;
  name: string;
  email: string;
  avatar: string;
}

function UserProfile({ userId }: { userId: string }) {
  const { data, error, isLoading, isValidating, mutate } = useSWR<User>(
    `/api/users/${userId}`,
    fetcher
  );

  if (isLoading) return <div>Cargando...</div>;
  if (error) return <div>Ha ocurrido un error</div>;
  if (!data) return null;

  return (
    <div>
      <img src={data.avatar} alt={data.name} />
      <h1>{data.name}</h1>
      <p>{data.email}</p>
      {isValidating && <span>Actualizando...</span>}
      <button onClick={() => mutate()}>Recargar</button>
    </div>
  );
}

Configuracion global

// app/providers.tsx
import { SWRConfig } from 'swr';
import { fetcher } from '@/lib/fetcher';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <SWRConfig
      value={{
        fetcher,
        // Configuracion global
        revalidateOnFocus: true,
        revalidateOnReconnect: true,
        refreshInterval: 0,
        shouldRetryOnError: true,
        errorRetryCount: 3,
        errorRetryInterval: 5000,
        dedupingInterval: 2000,
        // Manejo de errores
        onError: (error, key) => {
          console.error(`SWR Error [${key}]:`, error);
        },
        onSuccess: (data, key) => {
          console.log(`SWR Success [${key}]:`, data);
        },
      }}
    >
      {children}
    </SWRConfig>
  );
}

Opciones de configuracion SWR

CategoriaOpcionDescripcion
Momento de revalidacionrevalidateOnFocus: trueAl obtener foco
revalidateOnReconnect: trueAl reconectar
refreshInterval: 0Actualizacion periodica (ms)
refreshWhenHidden: falseActualizacion cuando esta oculto
refreshWhenOffline: falseActualizacion cuando esta offline
RendimientodedupingInterval: 2000Intervalo de deduplicacion (ms)
focusThrottleInterval: 5000Limitacion de foco
loadingTimeout: 3000Umbral de carga
Manejo de erroresshouldRetryOnError: trueReintentar en error
errorRetryCount: 3Numero de reintentos
errorRetryInterval: 5000Intervalo de reintentos

Fetch condicional

// Solo hacer fetch si el usuario esta logueado
function Dashboard() {
  const { user } = useAuth();

  // Si user es null, omitir fetch
  const { data: profile } = useSWR(
    user ? `/api/users/${user.id}/profile` : null,
    fetcher
  );

  // Retornar key con funcion
  const { data: posts } = useSWR(
    () => (user ? `/api/users/${user.id}/posts` : null),
    fetcher
  );

  return (
    <div>
      {profile && <ProfileCard profile={profile} />}
      {posts && <PostList posts={posts} />}
    </div>
  );
}

// Fetch con dependencias
function UserPosts({ userId }: { userId: string }) {
  const { data: user } = useSWR<User>(`/api/users/${userId}`, fetcher);

  // Ejecutar despues de que user sea obtenido
  const { data: posts } = useSWR<Post[]>(
    user ? `/api/users/${user.id}/posts` : null,
    fetcher
  );

  return (
    <div>
      <h1>Posts de {user?.name}</h1>
      {posts?.map(post => <PostCard key={post.id} post={post} />)}
    </div>
  );
}

Mutaciones

Actualizacion optimista

import useSWR, { useSWRConfig } from 'swr';

interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

function TodoList() {
  const { data: todos, mutate } = useSWR<Todo[]>('/api/todos', fetcher);
  const { mutate: globalMutate } = useSWRConfig();

  const toggleTodo = async (todo: Todo) => {
    const updatedTodo = { ...todo, completed: !todo.completed };

    // Actualizacion optimista: actualizar UI inmediatamente
    mutate(
      todos?.map(t => t.id === todo.id ? updatedTodo : t),
      false // Omitir revalidacion
    );

    try {
      // Enviar a la API
      await fetch(`/api/todos/${todo.id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ completed: updatedTodo.completed }),
      });

      // Revalidar despues del exito
      mutate();
    } catch (error) {
      // Rollback en caso de error
      mutate(todos, false);
      alert('La actualizacion ha fallado');
    }
  };

  const addTodo = async (title: string) => {
    const tempId = `temp-${Date.now()}`;
    const newTodo: Todo = { id: tempId, title, completed: false };

    // Agregar optimistamente
    mutate([...(todos || []), newTodo], false);

    try {
      const res = await fetch('/api/todos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title }),
      });

      const createdTodo = await res.json();

      // Reemplazar ID temporal con ID real
      mutate(
        todos?.map(t => t.id === tempId ? createdTodo : t),
        false
      );
    } catch (error) {
      // Rollback
      mutate(todos?.filter(t => t.id !== tempId), false);
    }
  };

  return (
    <ul>
      {todos?.map(todo => (
        <li key={todo.id} onClick={() => toggleTodo(todo)}>
          <input type="checkbox" checked={todo.completed} readOnly />
          {todo.title}
        </li>
      ))}
    </ul>
  );
}

Mutacion explicita con useSWRMutation

import useSWRMutation from 'swr/mutation';

interface CreatePostInput {
  title: string;
  content: string;
}

async function createPost(url: string, { arg }: { arg: CreatePostInput }) {
  const res = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(arg),
  });
  return res.json();
}

function CreatePostForm() {
  const { trigger, isMutating, error } = useSWRMutation(
    '/api/posts',
    createPost
  );

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    try {
      const result = await trigger({
        title: formData.get('title') as string,
        content: formData.get('content') as string,
      });
      console.log('Post creado:', result);
    } catch (error) {
      console.error('Publicacion fallida:', error);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" placeholder="Titulo" required />
      <textarea name="content" placeholder="Contenido" required />
      <button type="submit" disabled={isMutating}>
        {isMutating ? 'Publicando...' : 'Publicar'}
      </button>
      {error && <p>Error: {error.message}</p>}
    </form>
  );
}

Scroll infinito

import useSWRInfinite from 'swr/infinite';

interface Post {
  id: string;
  title: string;
  content: string;
}

interface PostsResponse {
  posts: Post[];
  nextCursor: string | null;
}

const PAGE_SIZE = 10;

function InfinitePostList() {
  const getKey = (pageIndex: number, previousPageData: PostsResponse | null) => {
    // Llegamos a la ultima pagina
    if (previousPageData && !previousPageData.nextCursor) return null;

    // Primera pagina
    if (pageIndex === 0) return `/api/posts?limit=${PAGE_SIZE}`;

    // Siguiente pagina
    return `/api/posts?cursor=${previousPageData?.nextCursor}&limit=${PAGE_SIZE}`;
  };

  const {
    data,
    error,
    size,
    setSize,
    isLoading,
    isValidating,
  } = useSWRInfinite<PostsResponse>(getKey, fetcher);

  const posts = data?.flatMap(page => page.posts) ?? [];
  const isLoadingMore = isLoading || (size > 0 && data && typeof data[size - 1] === 'undefined');
  const isEmpty = data?.[0]?.posts.length === 0;
  const isReachingEnd = isEmpty || (data && !data[data.length - 1]?.nextCursor);

  return (
    <div>
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}

      {isLoadingMore && <div>Cargando...</div>}

      {!isReachingEnd && (
        <button
          onClick={() => setSize(size + 1)}
          disabled={isLoadingMore}
        >
          Cargar mas
        </button>
      )}

      {isReachingEnd && !isEmpty && (
        <p>Se han mostrado todas las publicaciones</p>
      )}
    </div>
  );
}

Funcionamiento del scroll infinito

flowchart TB
    subgraph Page0["pageIndex: 0"]
        Req0["/api/posts?limit=10"]
        Res0["{ posts: [...], nextCursor: 'abc' }"]
    end
    subgraph Page1["pageIndex: 1"]
        Req1["/api/posts?cursor=abc&limit=10"]
        Res1["{ posts: [...], nextCursor: 'def' }"]
    end
    subgraph Page2["pageIndex: 2"]
        Req2["/api/posts?cursor=def&limit=10"]
        Res2["{ posts: [...], nextCursor: null }"]
    end
    End["nextCursor: null → Fin"]

    Req0 --> Res0
    Res0 --> Req1
    Req1 --> Res1
    Res1 --> Req2
    Req2 --> Res2
    Res2 --> End

Hooks personalizados

// hooks/usePosts.ts
import useSWR from 'swr';
import { fetcher } from '@/lib/fetcher';

interface Post {
  id: string;
  title: string;
  content: string;
  author: { id: string; name: string };
  createdAt: string;
}

interface UsePostsOptions {
  limit?: number;
  tag?: string;
}

export function usePosts(options: UsePostsOptions = {}) {
  const { limit = 10, tag } = options;
  const params = new URLSearchParams();
  params.set('limit', String(limit));
  if (tag) params.set('tag', tag);

  return useSWR<Post[]>(
    `/api/posts?${params.toString()}`,
    fetcher,
    {
      revalidateOnFocus: false,
      dedupingInterval: 10000,
    }
  );
}

export function usePost(id: string | null) {
  return useSWR<Post>(
    id ? `/api/posts/${id}` : null,
    fetcher
  );
}

// hooks/useUser.ts
export function useUser() {
  const { data, error, isLoading, mutate } = useSWR<User>(
    '/api/auth/me',
    fetcher,
    {
      revalidateOnFocus: true,
      errorRetryCount: 0, // No reintentar en errores de autenticacion
    }
  );

  return {
    user: data,
    isLoading,
    isLoggedIn: !!data && !error,
    isError: error,
    mutate,
  };
}

Prefetch

import { preload } from 'swr';
import { fetcher } from '@/lib/fetcher';

// Prefetch al hacer hover
function PostLink({ postId }: { postId: string }) {
  const handleMouseEnter = () => {
    preload(`/api/posts/${postId}`, fetcher);
  };

  return (
    <Link
      href={`/posts/${postId}`}
      onMouseEnter={handleMouseEnter}
    >
      Ver detalles
    </Link>
  );
}

// Prefetch al cargar la pagina
function PostsPage() {
  useEffect(() => {
    // Prefetch posts populares
    preload('/api/posts/popular', fetcher);
  }, []);

  return <div>...</div>;
}

// Datos iniciales con SSR/SSG
export async function getStaticProps() {
  const posts = await fetcher('/api/posts');

  return {
    props: {
      fallback: {
        '/api/posts': posts,
      },
    },
  };
}

function Page({ fallback }) {
  return (
    <SWRConfig value={{ fallback }}>
      <PostList />
    </SWRConfig>
  );
}

Manejo de errores

import useSWR from 'swr';

class APIError extends Error {
  status: number;

  constructor(message: string, status: number) {
    super(message);
    this.status = status;
  }
}

const fetcher = async (url: string) => {
  const res = await fetch(url);

  if (!res.ok) {
    const error = new APIError(
      'La solicitud API ha fallado',
      res.status
    );
    throw error;
  }

  return res.json();
};

function UserProfile({ userId }: { userId: string }) {
  const { data, error, isLoading } = useSWR<User, APIError>(
    `/api/users/${userId}`,
    fetcher
  );

  if (isLoading) return <LoadingSpinner />;

  if (error) {
    switch (error.status) {
      case 404:
        return <NotFound message="Usuario no encontrado" />;
      case 401:
        return <Redirect to="/login" />;
      case 500:
        return <ErrorPage message="Ha ocurrido un error en el servidor" />;
      default:
        return <ErrorPage message={error.message} />;
    }
  }

  return <ProfileCard user={data!} />;
}

// Combinacion con Error Boundary
function DataFetchingErrorBoundary({ children }: { children: React.ReactNode }) {
  return (
    <SWRConfig
      value={{
        onError: (error, key) => {
          // Enviar a servicio de reporte de errores
          reportError(error, { key });
        },
      }}
    >
      {children}
    </SWRConfig>
  );
}

Comparacion SWR vs React Query

FuncionalidadSWRReact Query
Tamano del bundle~4KB~13KB
Curva de aprendizajeBajaMedia
DevToolsNoCompleto
MutacionesSimpleAvanzado
Scroll infinitouseSWRInfiniteuseInfiniteQuery
Soporte SSRBuenoBueno
Control de cacheSimpleDetallado

Enlaces de referencia

← Volver a la lista