CRUD con TanStack Query
CRUD con TanStack Query
Section titled “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.
Configuración del Proyecto
Section titled “Configuración del Proyecto”Primero, definamos la estructura de datos y las funciones API:
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;}
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'); } },};
Hook Personalizado para Tareas
Section titled “Hook Personalizado para Tareas”Creemos un hook personalizado que encapsule toda la lógica de las operaciones CRUD:
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, };};
Componente de Lista de Tareas
Section titled “Componente de Lista de Tareas”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> );};
Modal de Edición
Section titled “Modal de Edición”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> );};
Mejores Prácticas Implementadas
Section titled “Mejores Prácticas Implementadas”1. Optimistic Updates
Section titled “1. Optimistic Updates”- 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
2. Manejo de Estados de Carga
Section titled “2. Manejo de Estados de Carga”- Estados específicos para cada operación (isCreating, isUpdating, isDeleting)
- UI responsive que refleja el estado actual
3. Invalidación Inteligente del Cache
Section titled “3. Invalidación Inteligente del Cache”- Se invalida el cache después de mutaciones exitosas
- Mantiene los datos sincronizados con el servidor
4. Manejo de Errores
Section titled “4. Manejo de Errores”- Rollback automático en caso de errores
- Mensajes de error informativos para el usuario
5. Keys de Query Consistentes
Section titled “5. Keys de Query Consistentes”- Uso de constantes para las query keys
- Facilita la invalidación y el manejo del cache
Consideraciones de Performance
Section titled “Consideraciones de Performance”// 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.
Próximos Pasos
Section titled “Próximos Pasos”- Añadir filtros y búsqueda - Ver Filtros y Búsqueda
- Implementar paginación - Ver Paginación
- Añadir validación de formularios con librerías como Zod o Yup
- Implementar notificaciones para feedback visual de las operaciones