TechLead

TanStack Query (React Query)

Potente gestión de estado del servidor con caché, actualizaciones en segundo plano y más

TanStack Query - Gestión de Estado del Servidor

TanStack Query (anteriormente React Query) es una poderosa biblioteca de obtención de datos y gestión de estado del servidor. Maneja el caché, actualizaciones en segundo plano, datos obsoletos, paginación y más desde el principio. Está específicamente diseñada para gestionar estado del servidor - datos que viven en tu backend.

Características Clave

  • Caché Automático — Cachea respuestas y reduce llamadas a API
  • Actualizaciones en Segundo Plano — Revalida datos obsoletos automáticamente
  • Deduplicación de Solicitudes — Deduplica solicitudes concurrentes idénticas
  • Actualizaciones Optimistas — Actualiza la UI antes de que el servidor confirme
  • Consultas Infinitas — Paginación y scroll infinito fácil

Instalación

npm install @tanstack/react-query

Configuración

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

// Crea un cliente
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60, // 1 minuto
      retry: 3,
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MyApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Consulta Básica

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

// Función de obtención
async function fetchTodos() {
  const response = await fetch('/api/todos');
  if (!response.ok) throw new Error('Error al obtener');
  return response.json();
}

function TodoList() {
  const { data, isLoading, isError, error, refetch } = useQuery({
    queryKey: ['todos'],           // Clave única para caché
    queryFn: fetchTodos,           // Función que devuelve una promesa
  });
  
  if (isLoading) return <div>Cargando...</div>;
  if (isError) return <div>Error: {error.message}</div>;
  
  return (
    <div>
      <button onClick={() => refetch()}>Actualizar</button>
      <ul>
        {data.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </div>
  );
}

Consulta con Parámetros

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

async function fetchUser(userId) {
  const response = await fetch(`/api/users/${userId}`);
  return response.json();
}

function UserProfile({ userId }) {
  const { data: user, isLoading } = useQuery({
    queryKey: ['user', userId],    // La clave incluye el parámetro
    queryFn: () => fetchUser(userId),
    enabled: !!userId,             // Solo ejecuta si existe userId
  });
  
  if (isLoading) return <div>Cargando...</div>;
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

// Consultas dependientes
function UserPosts({ userId }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });
  
  const { data: posts } = useQuery({
    queryKey: ['posts', user?.id],
    queryFn: () => fetchPosts(user.id),
    enabled: !!user?.id,           // Solo obtiene cuando el usuario está cargado
  });
  
  return (/* ... */);
}

Mutaciones

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

async function createTodo(newTodo) {
  const response = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(newTodo),
  });
  return response.json();
}

function AddTodo() {
  const queryClient = useQueryClient();
  const [text, setText] = useState('');
  
  const mutation = useMutation({
    mutationFn: createTodo,
    onSuccess: () => {
      // Invalida y revalida todos
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      setText('');
    },
    onError: (error) => {
      console.error('Error al crear tarea:', error);
    },
  });
  
  const handleSubmit = (e) => {
    e.preventDefault();
    mutation.mutate({ text, completed: false });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input 
        value={text} 
        onChange={(e) => setText(e.target.value)}
        disabled={mutation.isPending}
      />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Agregando...' : 'Agregar Tarea'}
      </button>
    </form>
  );
}

Actualizaciones Optimistas

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

function TodoItem({ todo }) {
  const queryClient = useQueryClient();
  
  const toggleMutation = useMutation({
    mutationFn: (todoId) => fetch(`/api/todos/${todoId}/toggle`, { method: 'PATCH' }),
    
    // Actualización optimista
    onMutate: async (todoId) => {
      // Cancela revalidaciones salientes
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      
      // Captura valor anterior
      const previousTodos = queryClient.getQueryData(['todos']);
      
      // Actualiza optimistamente
      queryClient.setQueryData(['todos'], (old) =>
        old.map(t => t.id === todoId ? { ...t, completed: !t.completed } : t)
      );
      
      // Devuelve contexto con captura
      return { previousTodos };
    },
    
    // Si falla la mutación, revertir
    onError: (err, todoId, context) => {
      queryClient.setQueryData(['todos'], context.previousTodos);
    },
    
    // Siempre revalida después de error o éxito
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
  
  return (
    <li onClick={() => toggleMutation.mutate(todo.id)}>
      {todo.completed ? '✓' : '○'} {todo.text}
    </li>
  );
}

Consultas Infinitas

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

async function fetchPosts({ pageParam = 1 }) {
  const response = await fetch(`/api/posts?page=${pageParam}&limit=10`);
  return response.json();
}

function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    getNextPageParam: (lastPage, pages) => {
      // Devuelve undefined si no hay más páginas
      return lastPage.hasMore ? pages.length + 1 : undefined;
    },
  });
  
  if (isLoading) return <div>Cargando...</div>;
  
  return (
    <div>
      {data.pages.map((page, i) => (
        <div key={i}>
          {page.posts.map(post => (
            <PostCard key={post.id} post={post} />
          ))}
        </div>
      ))}
      
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? 'Cargando más...'
          : hasNextPage
            ? 'Cargar Más'
            : 'No hay más posts'}
      </button>
    </div>
  );
}

Estados de Consulta

Estado Descripción
isPending La consulta aún no tiene datos (primera carga)
isLoading isPending && isFetching (carga inicial)
isFetching Cualquier obtención en progreso (incluyendo segundo plano)
isStale Los datos son más antiguos que staleTime
isSuccess La consulta tiene datos y sin error

💡 Mejores Prácticas

  • • Usa claves de consulta descriptivas: ['todos', { filter, sort }]
  • • Establece staleTime apropiado para tus necesidades de frescura de datos
  • • Usa la opción enabled para controlar cuándo se ejecutan las consultas
  • • Invalida consultas después de mutaciones
  • • Usa actualizaciones optimistas para mejor UX
  • • Instala React Query DevTools para depuración