Capítulo 10: Configuración de TanStack Query - Manejo de Datos del Servidor
Capítulo 10: Configuración de TanStack Query - Manejo de Datos del Servidor
En este capítulo configuraremos TanStack Query (anteriormente React Query) para manejar profesionalmente los datos del servidor, con cache inteligente, retry automático, y sincronización con autenticación.
🎯 Objetivos del Capítulo
- Instalar y configurar TanStack Query
- Crear QueryClient con configuración optimizada
- Integrar con autenticación de Clerk
- Configurar DevTools para desarrollo
- Establecer patrones de cache y retry
📋 Prerrequisitos
- Capítulo 9 completado (Gestión de Usuario y Sesiones)
- Sistema de autenticación funcionando
- Conocimiento básico de APIs REST
📦 Instalación de TanStack Query
Primero instalemos las dependencias necesarias:
# TanStack Query para React Native
npm install @tanstack/react-query
# DevTools para desarrollo (opcional pero recomendado)
npm install @tanstack/react-query-devtools
# Async Storage para persistencia
npm install @react-native-async-storage/async-storage
# Network Info para detectar conectividad
npm install @react-native-community/netinfo
🔧 Configuración del QueryClient
Creemos una configuración robusta del QueryClient:
// src/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
import NetInfo from '@react-native-community/netinfo';
// Configuración del QueryClient optimizada para React Native
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Cache por 5 minutos por defecto
staleTime: 1000 * 60 * 5,
// Mantener datos en cache por 10 minutos
gcTime: 1000 * 60 * 10,
// Retry automático en caso de error
retry: (failureCount, error: any) => {
// No retry para errores 4xx (errores del cliente)
if (error?.status >= 400 && error?.status < 500) {
return false;
}
// Máximo 3 reintentos para otros errores
return failureCount < 3;
},
// Delay exponencial para retries
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
// Solo refetch cuando la app vuelve al foco si los datos están stale
refetchOnWindowFocus: 'always',
// Refetch cuando se reconecta la red
refetchOnReconnect: 'always',
// No refetch automático en mount si los datos son frescos
refetchOnMount: true,
// Función para detectar si estamos online
networkMode: 'online',
},
mutations: {
// Retry para mutations críticas
retry: 1,
// Timeout para mutations
networkMode: 'online',
},
},
});
// Configurar detección de red
NetInfo.configure({
reachabilityUrl: 'https://clients3.google.com/generate_204',
reachabilityTest: async (response) => response.status === 204,
reachabilityLongTimeout: 60 * 1000, // 60s
reachabilityShortTimeout: 5 * 1000, // 5s
reachabilityRequestTimeout: 15 * 1000, // 15s
});
// Función para invalidar queries cuando el usuario cambia
export const invalidateUserQueries = () => {
queryClient.invalidateQueries({
predicate: (query) => {
// Invalidar todas las queries que dependan del usuario
return query.queryKey.includes('user') ||
query.queryKey.includes('tasks') ||
query.queryKey.includes('profile');
},
});
};
// Función para limpiar cache al logout
export const clearUserCache = () => {
queryClient.clear();
};
🏗️ Provider de TanStack Query
Creemos el provider principal que envolverá nuestra aplicación:
// src/providers/QueryProvider.tsx
import React, { useEffect } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import NetInfo from '@react-native-community/netinfo';
import { onlineManager } from '@tanstack/react-query';
import { AppState, Platform } from 'react-native';
import { focusManager } from '@tanstack/react-query';
import { queryClient } from '../lib/queryClient';
interface QueryProviderProps {
children: React.ReactNode;
}
export const QueryProvider: React.FC<QueryProviderProps> = ({ children }) => {
useEffect(() => {
// Configurar detección de estado online/offline
const unsubscribe = NetInfo.addEventListener((state) => {
onlineManager.setOnline(
state.isConnected != null &&
state.isConnected &&
Boolean(state.isInternetReachable)
);
});
return () => unsubscribe();
}, []);
useEffect(() => {
// Configurar focus manager para refetch cuando la app vuelve al foco
const subscription = AppState.addEventListener('change', (status) => {
if (Platform.OS !== 'web') {
focusManager.setFocused(status === 'active');
}
});
return () => subscription?.remove();
}, []);
return (
<QueryClientProvider client={queryClient}>
{children}
{__DEV__ && (
<ReactQueryDevtools
initialIsOpen={false}
position="bottom-right"
/>
)}
</QueryClientProvider>
);
};
🔐 Integración con Autenticación
Creemos un hook que integre TanStack Query con Clerk:
// src/hooks/useAuthenticatedQuery.ts
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { useAuth } from '@clerk/clerk-expo';
import { useEffect } from 'react';
import { queryClient } from '../lib/queryClient';
export function useAuthenticatedQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends readonly unknown[] = readonly unknown[]
>(
queryKey: TQueryKey,
queryFn: () => Promise<TQueryFnData>,
options?: Omit<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryKey' | 'queryFn'
>
) {
const { isSignedIn, userId } = useAuth();
// Invalidar queries cuando el usuario cambia
useEffect(() => {
if (userId) {
// Opcional: invalidar queries específicas cuando cambia el usuario
queryClient.invalidateQueries({
queryKey: ['user', userId],
});
}
}, [userId]);
return useQuery({
queryKey: ['authenticated', userId, ...queryKey] as const,
queryFn: queryFn,
enabled: isSignedIn && !!userId && (options?.enabled ?? true),
...options,
});
}
🔄 Hook para Mutations Autenticadas
// src/hooks/useAuthenticatedMutation.ts
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useAuth } from '@clerk/clerk-expo';
export function useAuthenticatedMutation<
TData = unknown,
TError = unknown,
TVariables = void,
TContext = unknown
>(
mutationFn: (variables: TVariables) => Promise<TData>,
options?: Omit<
UseMutationOptions<TData, TError, TVariables, TContext>,
'mutationFn'
>
) {
const { isSignedIn, getToken } = useAuth();
return useMutation({
mutationFn: async (variables: TVariables) => {
if (!isSignedIn) {
throw new Error('Usuario no autenticado');
}
// Obtener token fresco para la mutation
const token = await getToken();
if (!token) {
throw new Error('No se pudo obtener el token de autenticación');
}
return mutationFn(variables);
},
...options,
});
}
🌐 Configuración de Estado de Red
Creemos un hook para manejar el estado de la red:
// src/hooks/useNetworkState.ts
import { useEffect, useState } from 'react';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
import { onlineManager } from '@tanstack/react-query';
export interface NetworkState {
isConnected: boolean;
isInternetReachable: boolean;
type: string;
isOnline: boolean;
}
export const useNetworkState = () => {
const [networkState, setNetworkState] = useState<NetworkState>({
isConnected: true,
isInternetReachable: true,
type: 'unknown',
isOnline: true,
});
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
const isOnline =
state.isConnected != null &&
state.isConnected &&
Boolean(state.isInternetReachable);
setNetworkState({
isConnected: state.isConnected ?? false,
isInternetReachable: state.isInternetReachable ?? false,
type: state.type,
isOnline,
});
// Actualizar el estado en TanStack Query
onlineManager.setOnline(isOnline);
});
return () => unsubscribe();
}, []);
return networkState;
};
📊 Componente de Estado de Red
// src/components/NetworkStatus.tsx
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { useNetworkState } from '../hooks/useNetworkState';
import { useIsFetching, useIsMutating } from '@tanstack/react-query';
export const NetworkStatus: React.FC = () => {
const { isOnline, type } = useNetworkState();
const isFetching = useIsFetching();
const isMutating = useIsMutating();
if (isOnline && !isFetching && !isMutating) {
return null; // No mostrar nada si todo está bien
}
return (
<View style={[styles.container, !isOnline && styles.offline]}>
{!isOnline ? (
<Text style={styles.text}>Sin conexión a internet</Text>
) : (
<>
{isFetching > 0 && (
<Text style={styles.text}>
Cargando datos... ({isFetching})
</Text>
)}
{isMutating > 0 && (
<Text style={styles.text}>
Guardando cambios... ({isMutating})
</Text>
)}
</>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: '#007AFF',
padding: 8,
alignItems: 'center',
},
offline: {
backgroundColor: '#FF3B30',
},
text: {
color: '#fff',
fontSize: 12,
fontWeight: '500',
},
});
🔧 Configuración de Cache Persistente
// src/lib/persistentCache.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { QueryClient } from '@tanstack/react-query';
import { persistQueryClient } from '@tanstack/react-query-persist-client-core';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
// Crear persister con AsyncStorage
export const asyncStoragePersister = createAsyncStoragePersister({
storage: AsyncStorage,
key: 'REACT_QUERY_OFFLINE_CACHE',
// Serializar/deserializar fechas correctamente
serialize: JSON.stringify,
deserialize: JSON.parse,
});
// Función para configurar persistencia
export const setupPersistentCache = (queryClient: QueryClient) => {
persistQueryClient({
queryClient,
persister: asyncStoragePersister,
// Cache por 24 horas
maxAge: 1000 * 60 * 60 * 24,
// Solo persistir queries específicas
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
// Solo persistir queries que no sean sensibles
const queryKey = query.queryKey[0] as string;
const persistableQueries = ['tasks', 'categories', 'public'];
return persistableQueries.some(key => queryKey.includes(key));
},
},
});
};
🎛️ DevTools y Debugging
// src/components/QueryDevTools.tsx
import React from 'react';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
export const QueryDevTools: React.FC = () => {
if (!__DEV__) {
return null;
}
return (
<ReactQueryDevtools
initialIsOpen={false}
position="bottom-right"
toggleButtonProps={{
style: {
marginBottom: 80, // Espacio para tab bar
},
}}
/>
);
};
🧪 Testing con TanStack Query
// src/utils/test-utils-query.tsx
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, RenderOptions } from '@testing-library/react-native';
// Crear QueryClient para testing
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Infinity,
},
mutations: {
retry: false,
},
},
logger: {
log: console.log,
warn: console.warn,
error: () => {}, // Silenciar errores en tests
},
});
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
queryClient?: QueryClient;
}
const customRender = (
ui: React.ReactElement,
options: CustomRenderOptions = {}
) => {
const { queryClient = createTestQueryClient(), ...renderOptions } = options;
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
return {
...render(ui, { wrapper: Wrapper, ...renderOptions }),
queryClient,
};
};
export * from '@testing-library/react-native';
export { customRender as render };
🧪 Tests para Hooks de Query
// src/hooks/__tests__/useAuthenticatedQuery.test.ts
import { renderHook, waitFor } from '@testing-library/react-native';
import { useAuthenticatedQuery } from '../useAuthenticatedQuery';
import { useAuth } from '@clerk/clerk-expo';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
jest.mock('@clerk/clerk-expo');
const mockUseAuth = useAuth as jest.MockedFunction<typeof useAuth>;
describe('useAuthenticatedQuery', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
jest.clearAllMocks();
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
it('should not fetch when user is not signed in', () => {
mockUseAuth.mockReturnValue({
isSignedIn: false,
userId: null,
});
const mockQueryFn = jest.fn().mockResolvedValue('data');
const { result } = renderHook(
() => useAuthenticatedQuery(['test'], mockQueryFn),
{ wrapper }
);
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(mockQueryFn).not.toHaveBeenCalled();
});
it('should fetch when user is signed in', async () => {
mockUseAuth.mockReturnValue({
isSignedIn: true,
userId: 'user_123',
});
const mockQueryFn = jest.fn().mockResolvedValue('test data');
const { result } = renderHook(
() => useAuthenticatedQuery(['test'], mockQueryFn),
{ wrapper }
);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toBe('test data');
expect(mockQueryFn).toHaveBeenCalledTimes(1);
});
it('should include userId in query key', () => {
mockUseAuth.mockReturnValue({
isSignedIn: true,
userId: 'user_123',
});
const mockQueryFn = jest.fn().mockResolvedValue('data');
renderHook(
() => useAuthenticatedQuery(['test'], mockQueryFn),
{ wrapper }
);
const queries = queryClient.getQueryCache().getAll();
expect(queries[0].queryKey).toEqual([
'authenticated',
'user_123',
'test',
]);
});
});
🔄 Hook para Invalidación Inteligente
// src/hooks/useQueryInvalidation.ts
import { useQueryClient } from '@tanstack/react-query';
import { useAuth } from '@clerk/clerk-expo';
import { useCallback } from 'react';
export const useQueryInvalidation = () => {
const queryClient = useQueryClient();
const { userId } = useAuth();
const invalidateUserQueries = useCallback(() => {
if (!userId) return;
queryClient.invalidateQueries({
queryKey: ['authenticated', userId],
});
}, [queryClient, userId]);
const invalidateSpecificQuery = useCallback((queryKey: unknown[]) => {
if (!userId) return;
queryClient.invalidateQueries({
queryKey: ['authenticated', userId, ...queryKey],
});
}, [queryClient, userId]);
const refetchUserQueries = useCallback(() => {
if (!userId) return;
return queryClient.refetchQueries({
queryKey: ['authenticated', userId],
});
}, [queryClient, userId]);
const clearUserCache = useCallback(() => {
if (!userId) return;
queryClient.removeQueries({
queryKey: ['authenticated', userId],
});
}, [queryClient, userId]);
return {
invalidateUserQueries,
invalidateSpecificQuery,
refetchUserQueries,
clearUserCache,
};
};
🔧 Actualización del App Principal
// App.tsx
import React from 'react';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { ClerkProvider } from './src/providers/ClerkProvider';
import { QueryProvider } from './src/providers/QueryProvider';
import { MainNavigator } from './src/navigation/MainNavigator';
import { NetworkStatus } from './src/components/NetworkStatus';
export default function App() {
return (
<SafeAreaProvider>
<ClerkProvider>
<QueryProvider>
<NetworkStatus />
<MainNavigator />
</QueryProvider>
</ClerkProvider>
</SafeAreaProvider>
);
}
📈 Métricas y Monitoreo
// src/lib/queryMetrics.ts
import { QueryClient } from '@tanstack/react-query';
export const setupQueryMetrics = (queryClient: QueryClient) => {
// Listener para métricas de cache
queryClient.getQueryCache().subscribe((event) => {
if (event?.type === 'added') {
console.log('Query added to cache:', event.query.queryKey);
}
if (event?.type === 'removed') {
console.log('Query removed from cache:', event.query.queryKey);
}
});
// Listener para métricas de mutations
queryClient.getMutationCache().subscribe((event) => {
if (event?.type === 'added') {
console.log('Mutation started:', event.mutation.options.mutationKey);
}
});
};
export const getQueryMetrics = (queryClient: QueryClient) => {
const queries = queryClient.getQueryCache().getAll();
const mutations = queryClient.getMutationCache().getAll();
return {
totalQueries: queries.length,
freshQueries: queries.filter(q => q.state.dataUpdatedAt > Date.now() - 5 * 60 * 1000).length,
staleQueries: queries.filter(q => q.isStale()).length,
activeMutations: mutations.filter(m => m.state.status === 'pending').length,
cacheSize: JSON.stringify(queries.map(q => q.state.data)).length,
};
};
🚀 Siguientes Pasos
En el próximo capítulo implementaremos:
- Cliente HTTP con interceptors de autenticación
- Tipos TypeScript para API responses
- Manejo de errores y refresh de tokens
- Queries y mutations específicas para tareas
🎯 Resumen del Capítulo
Hemos configurado una infraestructura robusta para manejo de datos:
✅ TanStack Query configurado con opciones optimizadas ✅ Integración con autenticación de Clerk ✅ Detección de estado de red y manejo offline ✅ Cache persistente con AsyncStorage ✅ DevTools para desarrollo y debugging ✅ Hooks personalizados para queries autenticadas ✅ Testing configurado para queries y mutations
Próximo capítulo: API Client con Autenticación - Crearemos el cliente HTTP con interceptors y manejo de tokens.