← Volver al listado de tecnologías

Capítulo 10: Configuración de TanStack Query - Manejo de Datos del Servidor

Por: Artiko
react-nativetanstack-querycacheservidortypescripttesting

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

📋 Prerrequisitos

📦 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:

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