← Volver al listado de tecnologías

Capítulo 12: Queries y Mutations Autenticadas

Por: Artiko
react-nativetanstack-querymutationsoptimistic-updatescachetesting

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

📚 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

📚 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.