← Volver al listado de tecnologías
Capítulo 12: Queries y Mutations Autenticadas
Capítulo 12: Queries y Mutations Autenticadas
En este capítulo integraremos TanStack Query con nuestro API client autenticado para crear un sistema completo de gestión de datos del servidor con queries optimizadas, mutations con optimistic updates y cache management inteligente.
🎯 Objetivos del Capítulo
- Crear hooks personalizados para queries y mutations
- Implementar optimistic updates para mejor UX
- Configurar invalidación inteligente de cache
- Manejar estados de loading y error
- Implementar paginación y filtros
- Testing completo de queries y mutations
📚 Contenido
1. Hooks de Queries para Todos
Creamos hooks personalizados para gestionar todos:
// src/hooks/queries/useTodos.ts
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { TodoService } from '../../services/api/todoService';
import { Todo, PaginatedResponse } from '../../types/api';
interface UseTodosParams {
page?: number;
limit?: number;
completed?: boolean;
categoryId?: string;
}
export const useTodos = (params: UseTodosParams = {}): UseQueryResult<PaginatedResponse<Todo>, Error> => {
return useQuery({
queryKey: ['todos', params],
queryFn: () => TodoService.getTodos(params),
staleTime: 5 * 60 * 1000, // 5 minutos
gcTime: 10 * 60 * 1000, // 10 minutos
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
};
export const useTodoById = (id: string): UseQueryResult<Todo, Error> => {
return useQuery({
queryKey: ['todo', id],
queryFn: () => TodoService.getTodoById(id).then(response => response.data),
enabled: !!id,
staleTime: 5 * 60 * 1000,
});
};
2. Hooks de Mutations para Todos
Implementamos mutations con optimistic updates:
// src/hooks/mutations/useTodoMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { TodoService } from '../../services/api/todoService';
import { Todo, CreateTodoRequest, UpdateTodoRequest } from '../../types/api';
import { useToast } from '../useToast';
export const useCreateTodo = () => {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: (data: CreateTodoRequest) => TodoService.createTodo(data),
onMutate: async (newTodo) => {
// Cancelar queries salientes
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot del estado anterior
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistic update
queryClient.setQueryData(['todos'], (old: any) => {
if (!old) return old;
const optimisticTodo: Todo = {
id: `temp-${Date.now()}`,
title: newTodo.title,
description: newTodo.description || '',
completed: false,
priority: newTodo.priority || 'medium',
dueDate: newTodo.dueDate,
categoryId: newTodo.categoryId,
userId: 'current-user',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
return {
...old,
data: [optimisticTodo, ...old.data],
pagination: {
...old.pagination,
total: old.pagination.total + 1,
},
};
});
return { previousTodos };
},
onError: (err, newTodo, context) => {
// Revertir optimistic update
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
showToast('Error al crear la tarea', 'error');
},
onSuccess: (data) => {
showToast('Tarea creada exitosamente', 'success');
},
onSettled: () => {
// Invalidar y refetch
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
};
export const useUpdateTodo = () => {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateTodoRequest }) =>
TodoService.updateTodo(id, data),
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
await queryClient.cancelQueries({ queryKey: ['todo', id] });
const previousTodos = queryClient.getQueryData(['todos']);
const previousTodo = queryClient.getQueryData(['todo', id]);
// Optimistic update en lista
queryClient.setQueryData(['todos'], (old: any) => {
if (!old) return old;
return {
...old,
data: old.data.map((todo: Todo) =>
todo.id === id ? { ...todo, ...data, updatedAt: new Date().toISOString() } : todo
),
};
});
// Optimistic update en detalle
queryClient.setQueryData(['todo', id], (old: Todo) => {
if (!old) return old;
return { ...old, ...data, updatedAt: new Date().toISOString() };
});
return { previousTodos, previousTodo };
},
onError: (err, { id }, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
if (context?.previousTodo) {
queryClient.setQueryData(['todo', id], context.previousTodo);
}
showToast('Error al actualizar la tarea', 'error');
},
onSuccess: () => {
showToast('Tarea actualizada exitosamente', 'success');
},
onSettled: (data, error, { id }) => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
queryClient.invalidateQueries({ queryKey: ['todo', id] });
},
});
};
export const useDeleteTodo = () => {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: (id: string) => TodoService.deleteTodo(id),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old: any) => {
if (!old) return old;
return {
...old,
data: old.data.filter((todo: Todo) => todo.id !== id),
pagination: {
...old.pagination,
total: old.pagination.total - 1,
},
};
});
return { previousTodos };
},
onError: (err, id, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
showToast('Error al eliminar la tarea', 'error');
},
onSuccess: () => {
showToast('Tarea eliminada exitosamente', 'success');
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
};
export const useToggleTodo = () => {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: (id: string) => TodoService.toggleTodoComplete(id),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
await queryClient.cancelQueries({ queryKey: ['todo', id] });
const previousTodos = queryClient.getQueryData(['todos']);
const previousTodo = queryClient.getQueryData(['todo', id]);
// Optimistic toggle
queryClient.setQueryData(['todos'], (old: any) => {
if (!old) return old;
return {
...old,
data: old.data.map((todo: Todo) =>
todo.id === id
? { ...todo, completed: !todo.completed, updatedAt: new Date().toISOString() }
: todo
),
};
});
queryClient.setQueryData(['todo', id], (old: Todo) => {
if (!old) return old;
return { ...old, completed: !old.completed, updatedAt: new Date().toISOString() };
});
return { previousTodos, previousTodo };
},
onError: (err, id, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
if (context?.previousTodo) {
queryClient.setQueryData(['todo', id], context.previousTodo);
}
showToast('Error al cambiar estado de la tarea', 'error');
},
onSettled: (data, error, id) => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
queryClient.invalidateQueries({ queryKey: ['todo', id] });
},
});
};
3. Hooks para Categorías
// src/hooks/queries/useCategories.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CategoryService } from '../../services/api/categoryService';
import { Category, CreateCategoryRequest } from '../../types/api';
import { useToast } from '../useToast';
export const useCategories = () => {
return useQuery({
queryKey: ['categories'],
queryFn: () => CategoryService.getCategories().then(response => response.data),
staleTime: 10 * 60 * 1000, // 10 minutos
gcTime: 30 * 60 * 1000, // 30 minutos
});
};
export const useCategoryById = (id: string) => {
return useQuery({
queryKey: ['category', id],
queryFn: () => CategoryService.getCategoryById(id).then(response => response.data),
enabled: !!id,
staleTime: 10 * 60 * 1000,
});
};
export const useCreateCategory = () => {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: (data: CreateCategoryRequest) => CategoryService.createCategory(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['categories'] });
showToast('Categoría creada exitosamente', 'success');
},
onError: () => {
showToast('Error al crear la categoría', 'error');
},
});
};
export const useUpdateCategory = () => {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<CreateCategoryRequest> }) =>
CategoryService.updateCategory(id, data),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['categories'] });
queryClient.invalidateQueries({ queryKey: ['category', variables.id] });
showToast('Categoría actualizada exitosamente', 'success');
},
onError: () => {
showToast('Error al actualizar la categoría', 'error');
},
});
};
export const useDeleteCategory = () => {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: (id: string) => CategoryService.deleteCategory(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['categories'] });
queryClient.invalidateQueries({ queryKey: ['todos'] }); // Invalidar todos también
showToast('Categoría eliminada exitosamente', 'success');
},
onError: () => {
showToast('Error al eliminar la categoría', 'error');
},
});
};
4. Hook de Toast para Notificaciones
// src/hooks/useToast.ts
import { Alert } from 'react-native';
type ToastType = 'success' | 'error' | 'info' | 'warning';
export const useToast = () => {
const showToast = (message: string, type: ToastType = 'info') => {
const title = {
success: 'Éxito',
error: 'Error',
info: 'Información',
warning: 'Advertencia',
}[type];
Alert.alert(title, message);
};
return { showToast };
};
5. Hook Compuesto para Todos
// src/hooks/useTodosData.ts
import { useMemo } from 'react';
import { useTodos } from './queries/useTodos';
import { useCategories } from './queries/useCategories';
import { Todo, Category } from '../types/api';
interface UseTodosDataParams {
page?: number;
limit?: number;
completed?: boolean;
categoryId?: string;
}
export const useTodosData = (params: UseTodosDataParams = {}) => {
const todosQuery = useTodos(params);
const categoriesQuery = useCategories();
// Enriquecer todos con información de categorías
const enrichedTodos = useMemo(() => {
if (!todosQuery.data?.data || !categoriesQuery.data) {
return todosQuery.data?.data || [];
}
const categoriesMap = new Map(
categoriesQuery.data.map((cat: Category) => [cat.id, cat])
);
return todosQuery.data.data.map((todo: Todo) => ({
...todo,
category: todo.categoryId ? categoriesMap.get(todo.categoryId) : undefined,
}));
}, [todosQuery.data?.data, categoriesQuery.data]);
// Estadísticas derivadas
const stats = useMemo(() => {
if (!todosQuery.data?.data) {
return {
total: 0,
completed: 0,
pending: 0,
completionRate: 0,
};
}
const todos = todosQuery.data.data;
const completed = todos.filter(todo => todo.completed).length;
const pending = todos.length - completed;
return {
total: todos.length,
completed,
pending,
completionRate: todos.length > 0 ? (completed / todos.length) * 100 : 0,
};
}, [todosQuery.data?.data]);
return {
todos: enrichedTodos,
pagination: todosQuery.data?.pagination,
stats,
isLoading: todosQuery.isLoading || categoriesQuery.isLoading,
error: todosQuery.error || categoriesQuery.error,
refetch: todosQuery.refetch,
};
};
6. Componente TodoList con Queries
// src/components/TodoList.tsx
import React, { useState } from 'react';
import {
View,
Text,
FlatList,
RefreshControl,
StyleSheet,
TouchableOpacity,
Alert,
} from 'react-native';
import { useTodosData } from '../hooks/useTodosData';
import { useToggleTodo, useDeleteTodo } from '../hooks/mutations/useTodoMutations';
import { Todo } from '../types/api';
interface TodoListProps {
categoryId?: string;
completed?: boolean;
}
export const TodoList: React.FC<TodoListProps> = ({ categoryId, completed }) => {
const [page, setPage] = useState(1);
const limit = 10;
const { todos, pagination, stats, isLoading, error, refetch } = useTodosData({
page,
limit,
completed,
categoryId,
});
const toggleTodo = useToggleTodo();
const deleteTodo = useDeleteTodo();
const handleToggleTodo = (id: string) => {
toggleTodo.mutate(id);
};
const handleDeleteTodo = (id: string, title: string) => {
Alert.alert(
'Eliminar Tarea',
`¿Estás seguro de que quieres eliminar "${title}"?`,
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Eliminar',
style: 'destructive',
onPress: () => deleteTodo.mutate(id)
},
]
);
};
const loadMore = () => {
if (pagination && page < pagination.totalPages) {
setPage(prev => prev + 1);
}
};
const renderTodo = ({ item }: { item: Todo & { category?: any } }) => (
<View style={styles.todoItem}>
<TouchableOpacity
style={styles.todoContent}
onPress={() => handleToggleTodo(item.id)}
>
<View style={styles.todoHeader}>
<Text style={[
styles.todoTitle,
item.completed && styles.completedTitle
]}>
{item.title}
</Text>
<View style={[
styles.priorityBadge,
styles[`priority${item.priority.charAt(0).toUpperCase() + item.priority.slice(1)}`]
]}>
<Text style={styles.priorityText}>{item.priority}</Text>
</View>
</View>
{item.description && (
<Text style={[
styles.todoDescription,
item.completed && styles.completedText
]}>
{item.description}
</Text>
)}
<View style={styles.todoMeta}>
{item.category && (
<View style={[styles.categoryBadge, { backgroundColor: item.category.color }]}>
<Text style={styles.categoryText}>{item.category.name}</Text>
</View>
)}
{item.dueDate && (
<Text style={styles.dueDate}>
Vence: {new Date(item.dueDate).toLocaleDateString()}
</Text>
)}
</View>
</TouchableOpacity>
<TouchableOpacity
style={styles.deleteButton}
onPress={() => handleDeleteTodo(item.id, item.title)}
>
<Text style={styles.deleteButtonText}>🗑️</Text>
</TouchableOpacity>
</View>
);
if (error) {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Error al cargar las tareas</Text>
<TouchableOpacity style={styles.retryButton} onPress={() => refetch()}>
<Text style={styles.retryButtonText}>Reintentar</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.statsContainer}>
<Text style={styles.statsText}>
Total: {stats.total} | Completadas: {stats.completed} | Pendientes: {stats.pending}
</Text>
<Text style={styles.completionRate}>
{stats.completionRate.toFixed(1)}% completado
</Text>
</View>
<FlatList
data={todos}
renderItem={renderTodo}
keyExtractor={(item) => item.id}
refreshControl={
<RefreshControl
refreshing={isLoading}
onRefresh={refetch}
/>
}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No hay tareas</Text>
</View>
}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
statsContainer: {
backgroundColor: 'white',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
statsText: {
fontSize: 14,
color: '#666',
},
completionRate: {
fontSize: 16,
fontWeight: 'bold',
color: '#2196F3',
marginTop: 4,
},
todoItem: {
flexDirection: 'row',
backgroundColor: 'white',
marginHorizontal: 16,
marginVertical: 8,
borderRadius: 8,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
todoContent: {
flex: 1,
padding: 16,
},
todoHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
todoTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#333',
flex: 1,
},
completedTitle: {
textDecorationLine: 'line-through',
color: '#999',
},
todoDescription: {
fontSize: 14,
color: '#666',
marginBottom: 8,
},
completedText: {
color: '#999',
},
todoMeta: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
categoryBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
},
categoryText: {
fontSize: 12,
color: 'white',
fontWeight: 'bold',
},
dueDate: {
fontSize: 12,
color: '#666',
},
priorityBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
},
priorityHigh: {
backgroundColor: '#f44336',
},
priorityMedium: {
backgroundColor: '#ff9800',
},
priorityLow: {
backgroundColor: '#4caf50',
},
priorityText: {
fontSize: 12,
color: 'white',
fontWeight: 'bold',
},
deleteButton: {
justifyContent: 'center',
alignItems: 'center',
width: 60,
backgroundColor: '#f44336',
borderTopRightRadius: 8,
borderBottomRightRadius: 8,
},
deleteButtonText: {
fontSize: 20,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
errorText: {
fontSize: 16,
color: '#f44336',
marginBottom: 16,
textAlign: 'center',
},
retryButton: {
backgroundColor: '#2196F3',
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 6,
},
retryButtonText: {
color: 'white',
fontWeight: 'bold',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 40,
},
emptyText: {
fontSize: 16,
color: '#666',
textAlign: 'center',
},
});
7. Testing de Queries y Mutations
// src/hooks/queries/__tests__/useTodos.test.tsx
import { renderHook, waitFor } from '@testing-library/react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
import { useTodos } from '../useTodos';
import { server } from '../../../__mocks__/server';
import { rest } from 'msw';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
describe('useTodos', () => {
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('should fetch todos successfully', async () => {
const mockTodos = [
{
id: '1',
title: 'Test Todo',
completed: false,
priority: 'medium',
userId: 'user_123',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
];
server.use(
rest.get('http://localhost:3000/api/todos', (req, res, ctx) => {
return res(
ctx.json({
data: mockTodos,
pagination: {
page: 1,
limit: 10,
total: 1,
totalPages: 1,
},
})
);
})
);
const { result } = renderHook(() => useTodos(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.data).toEqual(mockTodos);
});
it('should handle query parameters', async () => {
server.use(
rest.get('http://localhost:3000/api/todos', (req, res, ctx) => {
const url = new URL(req.url);
expect(url.searchParams.get('completed')).toBe('true');
expect(url.searchParams.get('page')).toBe('2');
return res(
ctx.json({
data: [],
pagination: {
page: 2,
limit: 10,
total: 0,
totalPages: 1,
},
})
);
})
);
const { result } = renderHook(
() => useTodos({ completed: true, page: 2 }),
{ wrapper: createWrapper() }
);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
});
it('should handle errors', async () => {
server.use(
rest.get('http://localhost:3000/api/todos', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Server error' }));
})
);
const { result } = renderHook(() => useTodos(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBeDefined();
});
});
8. Invalidación Inteligente de Cache
// src/utils/cacheUtils.ts
import { QueryClient } from '@tanstack/react-query';
export class CacheManager {
constructor(private queryClient: QueryClient) {}
// Invalidar todos relacionados con un usuario específico
invalidateUserData(userId: string) {
this.queryClient.invalidateQueries({
predicate: (query) => {
const queryKey = query.queryKey;
return queryKey.includes('todos') || queryKey.includes('categories');
},
});
}
// Invalidar por categoría
invalidateCategoryData(categoryId: string) {
this.queryClient.invalidateQueries({
queryKey: ['todos'],
predicate: (query) => {
const params = query.queryKey[1] as any;
return params?.categoryId === categoryId;
},
});
}
// Limpiar cache obsoleto
clearStaleData() {
this.queryClient.clear();
}
// Prefetch de datos importantes
async prefetchImportantData() {
await Promise.all([
this.queryClient.prefetchQuery({
queryKey: ['todos', { page: 1, limit: 10 }],
queryFn: () => import('../../services/api/todoService').then(
({ TodoService }) => TodoService.getTodos({ page: 1, limit: 10 })
),
}),
this.queryClient.prefetchQuery({
queryKey: ['categories'],
queryFn: () => import('../../services/api/categoryService').then(
({ CategoryService }) => CategoryService.getCategories()
),
}),
]);
}
}
🔗 Navegación
- ← Capítulo 11: API Client con Autenticación
- → Capítulo 13: CRUD de Tareas por Usuario
- 🏠 Volver al índice
📚 Recursos Adicionales
¡Excelente! Has implementado un sistema completo de queries y mutations con optimistic updates y cache management inteligente. En el próximo capítulo crearemos la interfaz de usuario para el CRUD completo de tareas.