Skip to content

CRUD con TanStack Query

En este ejemplo práctico aprenderás a implementar un sistema completo de operaciones CRUD (Create, Read, Update, Delete) utilizando TanStack Query. Crearemos una aplicación de gestión de tareas que demuestra los patrones más comunes.

Primero, definamos la estructura de datos y las funciones API:

types/task.ts
export interface Task {
id: number;
title: string;
description: string;
completed: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateTaskData {
title: string;
description: string;
}
export interface UpdateTaskData {
id: number;
title?: string;
description?: string;
completed?: boolean;
}
api/tasks.ts
const API_BASE = 'https://api.ejemplo.com';
export const taskApi = {
// READ - Obtener todas las tareas
getTasks: async (): Promise<Task[]> => {
const response = await fetch(`${API_BASE}/tasks`);
if (!response.ok) {
throw new Error('Error al obtener las tareas');
}
return response.json();
},
// READ - Obtener una tarea específica
getTask: async (id: number): Promise<Task> => {
const response = await fetch(`${API_BASE}/tasks/${id}`);
if (!response.ok) {
throw new Error('Error al obtener la tarea');
}
return response.json();
},
// CREATE - Crear nueva tarea
createTask: async (data: CreateTaskData): Promise<Task> => {
const response = await fetch(`${API_BASE}/tasks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...data,
completed: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
});
if (!response.ok) {
throw new Error('Error al crear la tarea');
}
return response.json();
},
// UPDATE - Actualizar tarea existente
updateTask: async (data: UpdateTaskData): Promise<Task> => {
const { id, ...updateData } = data;
const response = await fetch(`${API_BASE}/tasks/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...updateData,
updatedAt: new Date().toISOString(),
}),
});
if (!response.ok) {
throw new Error('Error al actualizar la tarea');
}
return response.json();
},
// DELETE - Eliminar tarea
deleteTask: async (id: number): Promise<void> => {
const response = await fetch(`${API_BASE}/tasks/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Error al eliminar la tarea');
}
},
};

Creemos un hook personalizado que encapsule toda la lógica de las operaciones CRUD:

hooks/useTasks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { taskApi } from '../api/tasks';
import type { Task, CreateTaskData, UpdateTaskData } from '../types/task';
const TASKS_QUERY_KEY = ['tasks'] as const;
export const useTasks = () => {
const queryClient = useQueryClient();
// READ - Query para obtener todas las tareas
const tasksQuery = useQuery({
queryKey: TASKS_QUERY_KEY,
queryFn: taskApi.getTasks,
staleTime: 5 * 60 * 1000, // 5 minutos
});
// CREATE - Mutation para crear nueva tarea
const createTaskMutation = useMutation({
mutationFn: taskApi.createTask,
onSuccess: (newTask) => {
// Actualizar el cache optimísticamente
queryClient.setQueryData(TASKS_QUERY_KEY, (oldTasks: Task[] | undefined) => {
return oldTasks ? [...oldTasks, newTask] : [newTask];
});
// Invalidar para refrescar desde el servidor
queryClient.invalidateQueries({ queryKey: TASKS_QUERY_KEY });
},
onError: (error) => {
console.error('Error al crear tarea:', error);
},
});
// UPDATE - Mutation para actualizar tarea
const updateTaskMutation = useMutation({
mutationFn: taskApi.updateTask,
onMutate: async (updatedTask) => {
// Cancelar queries en vuelo
await queryClient.cancelQueries({ queryKey: TASKS_QUERY_KEY });
// Snapshot del estado anterior
const previousTasks = queryClient.getQueryData(TASKS_QUERY_KEY);
// Update optimístico
queryClient.setQueryData(TASKS_QUERY_KEY, (oldTasks: Task[] | undefined) => {
return oldTasks?.map(task =>
task.id === updatedTask.id
? { ...task, ...updatedTask }
: task
) ?? [];
});
return { previousTasks };
},
onError: (error, variables, context) => {
// Revertir en caso de error
if (context?.previousTasks) {
queryClient.setQueryData(TASKS_QUERY_KEY, context.previousTasks);
}
console.error('Error al actualizar tarea:', error);
},
onSettled: () => {
// Refrescar los datos del servidor
queryClient.invalidateQueries({ queryKey: TASKS_QUERY_KEY });
},
});
// DELETE - Mutation para eliminar tarea
const deleteTaskMutation = useMutation({
mutationFn: taskApi.deleteTask,
onMutate: async (taskId) => {
await queryClient.cancelQueries({ queryKey: TASKS_QUERY_KEY });
const previousTasks = queryClient.getQueryData(TASKS_QUERY_KEY);
// Remover optimísticamente
queryClient.setQueryData(TASKS_QUERY_KEY, (oldTasks: Task[] | undefined) => {
return oldTasks?.filter(task => task.id !== taskId) ?? [];
});
return { previousTasks };
},
onError: (error, variables, context) => {
if (context?.previousTasks) {
queryClient.setQueryData(TASKS_QUERY_KEY, context.previousTasks);
}
console.error('Error al eliminar tarea:', error);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: TASKS_QUERY_KEY });
},
});
return {
// Queries
tasks: tasksQuery.data ?? [],
isLoading: tasksQuery.isLoading,
error: tasksQuery.error,
// Mutations
createTask: createTaskMutation.mutate,
updateTask: updateTaskMutation.mutate,
deleteTask: deleteTaskMutation.mutate,
// Estados de las mutations
isCreating: createTaskMutation.isPending,
isUpdating: updateTaskMutation.isPending,
isDeleting: deleteTaskMutation.isPending,
};
};
components/TaskList.tsx
import React, { useState } from 'react';
import { useTasks } from '../hooks/useTasks';
import type { Task } from '../types/task';
export const TaskList: React.FC = () => {
const {
tasks,
isLoading,
error,
createTask,
updateTask,
deleteTask,
isCreating,
isUpdating,
isDeleting,
} = useTasks();
const [newTask, setNewTask] = useState({ title: '', description: '' });
const [editingTask, setEditingTask] = useState<Task | null>(null);
const handleCreateTask = (e: React.FormEvent) => {
e.preventDefault();
if (newTask.title.trim()) {
createTask(newTask, {
onSuccess: () => {
setNewTask({ title: '', description: '' });
},
});
}
};
const handleUpdateTask = (task: Task, updates: Partial<Task>) => {
updateTask({ id: task.id, ...updates });
};
const handleDeleteTask = (taskId: number) => {
if (window.confirm('¿Estás seguro de eliminar esta tarea?')) {
deleteTask(taskId);
}
};
if (isLoading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<p className="text-red-800">Error: {error.message}</p>
</div>
);
}
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-8">Gestión de Tareas</h1>
{/* Formulario para crear nueva tarea */}
<form onSubmit={handleCreateTask} className="mb-8 bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Nueva Tarea</h2>
<div className="space-y-4">
<input
type="text"
placeholder="Título de la tarea"
value={newTask.title}
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
required
/>
<textarea
placeholder="Descripción"
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
rows={3}
/>
<button
type="submit"
disabled={isCreating}
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isCreating ? 'Creando...' : 'Crear Tarea'}
</button>
</div>
</form>
{/* Lista de tareas */}
<div className="space-y-4">
{tasks.map((task) => (
<div key={task.id} className="bg-white p-6 rounded-lg shadow">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3">
<input
type="checkbox"
checked={task.completed}
onChange={(e) =>
handleUpdateTask(task, { completed: e.target.checked })
}
className="h-5 w-5 text-blue-600"
/>
<h3 className={`text-lg font-semibold ${
task.completed ? 'line-through text-gray-500' : ''
}`}>
{task.title}
</h3>
</div>
<p className="mt-2 text-gray-600">{task.description}</p>
<p className="mt-2 text-sm text-gray-400">
Creado: {new Date(task.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex space-x-2">
<button
onClick={() => setEditingTask(task)}
className="text-blue-600 hover:text-blue-800"
>
Editar
</button>
<button
onClick={() => handleDeleteTask(task.id)}
disabled={isDeleting}
className="text-red-600 hover:text-red-800 disabled:opacity-50"
>
{isDeleting ? 'Eliminando...' : 'Eliminar'}
</button>
</div>
</div>
</div>
))}
</div>
{tasks.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">No hay tareas aún. ¡Crea tu primera tarea!</p>
</div>
)}
{/* Modal de edición */}
{editingTask && (
<EditTaskModal
task={editingTask}
onSave={(updates) => {
handleUpdateTask(editingTask, updates);
setEditingTask(null);
}}
onClose={() => setEditingTask(null)}
isUpdating={isUpdating}
/>
)}
</div>
);
};
components/EditTaskModal.tsx
import React, { useState } from 'react';
import type { Task } from '../types/task';
interface EditTaskModalProps {
task: Task;
onSave: (updates: Partial<Task>) => void;
onClose: () => void;
isUpdating: boolean;
}
export const EditTaskModal: React.FC<EditTaskModalProps> = ({
task,
onSave,
onClose,
isUpdating,
}) => {
const [formData, setFormData] = useState({
title: task.title,
description: task.description,
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg max-w-md w-full p-6">
<h2 className="text-xl font-semibold mb-4">Editar Tarea</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
required
/>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
rows={3}
/>
<div className="flex space-x-3">
<button
type="submit"
disabled={isUpdating}
className="flex-1 bg-blue-600 text-white py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isUpdating ? 'Guardando...' : 'Guardar'}
</button>
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-300 text-gray-700 py-2 rounded-md hover:bg-gray-400"
>
Cancelar
</button>
</div>
</form>
</div>
</div>
);
};
  • Las operaciones UPDATE y DELETE muestran cambios inmediatamente
  • Se revierten automáticamente si hay errores
  • Mejora la percepción de velocidad de la aplicación
  • Estados específicos para cada operación (isCreating, isUpdating, isDeleting)
  • UI responsive que refleja el estado actual
  • Se invalida el cache después de mutaciones exitosas
  • Mantiene los datos sincronizados con el servidor
  • Rollback automático en caso de errores
  • Mensajes de error informativos para el usuario
  • Uso de constantes para las query keys
  • Facilita la invalidación y el manejo del cache
// hooks/useTasks.ts (versión optimizada)
export const useTasks = () => {
const queryClient = useQueryClient();
// Configuración optimizada
const tasksQuery = useQuery({
queryKey: TASKS_QUERY_KEY,
queryFn: taskApi.getTasks,
staleTime: 5 * 60 * 1000, // 5 minutos
gcTime: 10 * 60 * 1000, // 10 minutos (antes cacheTime)
refetchOnWindowFocus: false, // Evitar refetch excesivo
retry: (failureCount, error) => {
// Retry personalizado basado en el tipo de error
if (error.message.includes('404')) return false;
return failureCount < 3;
},
});
// ... resto del código
};

Este ejemplo completo demuestra cómo implementar un sistema CRUD robusto usando TanStack Query, incluyendo optimistic updates, manejo de errores, y una excelente experiencia de usuario.

  1. Añadir filtros y búsqueda - Ver Filtros y Búsqueda
  2. Implementar paginación - Ver Paginación
  3. Añadir validación de formularios con librerías como Zod o Yup
  4. Implementar notificaciones para feedback visual de las operaciones