POWR/docs/technical/react-query-integration.md

53 KiB

React Query Integration for POWR

Last Updated: April 3, 2025
Status: Implementation Plan
Authors: POWR Development Team

Table of Contents

  1. Overview
  2. Current Architecture Analysis
  3. React Query Integration Strategy
  4. Authentication & State Management
  5. Data Fetching & Caching
  6. Offline Support & Publication Queue
  7. Error Handling
  8. Conflict Resolution
  9. Implementation Roadmap
  10. API Reference
  11. Migration Guide

Overview

This document outlines the plan to integrate React Query (TanStack Query) into the POWR app to resolve critical issues with authentication state transitions, hook ordering, and offline synchronization while preserving our local-first architecture with Nostr integration.

Key Goals

  • Fix React hook ordering issues causing crashes during authentication state changes
  • Provide consistent data access patterns across the application
  • Enhance offline support with better synchronization
  • Improve error handling and recovery
  • Maintain local-first architecture with SQLite and Nostr integration

Architecture Principles

  1. SQLite Remains Primary Persistence Layer: All user data will continue to be persisted in SQLite
  2. React Query as Sync/Cache Layer: React Query manages data synchronization between UI, SQLite, and Nostr
  3. Zustand for UI State: Active workout state and UI-specific state remain in Zustand stores
  4. NDK for Nostr Integration: NDK remains the primary interface for Nostr communication

Current Architecture Analysis

Existing Data Flow

┌─ UI Components ─────────────────────────────────┐
│                                                 │
│  React Components (screens, etc.)               │
│                                                 │
└─────────────────┬───────────────────────────────┘
                  │
┌─────────────────▼───────────────────────────────┐
│                                                 │
│  Custom Hooks (useProfile, useSocialFeed, etc.) │
│                                                 │
└───────┬───────────────────────┬─────────────────┘
        │                       │
┌───────▼───────────┐  ┌────────▼────────────────┐
│                   │  │                         │
│  SQLite Services  │  │  NDK Integration        │
│  (Local Storage)  │  │  (Nostr Communication)  │
│                   │  │                         │
└───────────────────┘  └─────────────────────────┘

Current Authentication System

The current authentication system is implemented across several components:

  • lib/auth/AuthService.ts: Core authentication service
  • lib/auth/AuthStateManager.ts: State machine for auth transitions
  • lib/auth/SigningQueue.ts: Queue for NDK event signing
  • lib/hooks/useNDK.ts: Hook for accessing the NDK instance
  • lib/hooks/useNDKAuth.ts: Hook for authentication with NDK
  • lib/signers/NDKAmberSigner.ts: External signer integration for Android

The system implements a state machine pattern:

type AuthState = 
  | { status: 'unauthenticated' }
  | { status: 'authenticating', method: 'private_key' | 'amber' | 'ephemeral' }
  | { status: 'authenticated', user: NDKUser, method: 'private_key' | 'amber' | 'ephemeral' }
  | { status: 'signing', operationCount: number, operations: SigningOperation[] }
  | { status: 'error', error: Error };

However, hook ordering issues occur when components conditionally use hooks based on authentication state.

Current Caching System

The current caching system includes:

  • SocialFeedCache: SQLite-based caching for social feed events
  • ProfileImageCache: Caching for user profile images
  • ContactCacheService: Caching for user contact lists
  • EventCache: General-purpose cache for Nostr events
  • PublicationQueueService: Queue for pending Nostr publications

NDK Integration

NDK is integrated through:

  • lib/stores/ndk.ts: NDK store implementation
  • lib/hooks/useNDK.ts: Hook for accessing NDK
  • lib/initNDK.ts: NDK initialization
  • Various hooks for specific NDK operations

React Query Integration Strategy

New Architecture with React Query

┌─ UI Components ─────────────────────────────────┐
│                                                 │
│  React Components (screens, etc.)               │
│                                                 │
└─────────────────┬───────────────────────────────┘
                  │
                  ▼
┌─ Data Access Layer ───────────────────────────┐
│                                               │
│  React Query Hooks                            │
│  (useProfile, useWorkouts, useTemplates, etc.)│
│                                               │
└───────┬───────────────────────┬───────────────┘
        │                       │
        ▼                       ▼
┌───────────────────┐  ┌────────────────────────┐
│ SQLite Services   │  │ NDK Integration         │
│ (local storage)   │  │ (network/Nostr)         │
└───────────────────┘  └────────────────────────┘

Replacement Strategy

Our strategy is to use React Query to replace portions of our custom hooks that handle data fetching, caching, and synchronization, while preserving our SQLite services and NDK integration.

What React Query Will Replace

  • Custom caching logic in hooks
  • Manual state management for loading/error states
  • Ad-hoc fetch retry and synchronization logic
  • Authentication state propagation

What Will Remain Unchanged

  • SQLite database schema and services
  • NDK event handling and Nostr communication
  • Zustand stores for active workout state and UI-specific state
  • Core business logic

Key Benefits

  1. Consistent Hook Ordering: React Query preserves hook call order across renders
  2. Automatic Background Refetching: Built-in mechanisms for data freshness
  3. Standardized Loading/Error States: Consistent data loading patterns
  4. Optimistic Updates: Built-in support for optimistic UI updates
  5. Deduplication of Requests: Prevents redundant network/database calls

Authentication & State Management

React Query Auth Provider

We'll implement a centralized authentication system using React Query:

// lib/auth/ReactQueryAuthProvider.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createContext, useContext, useState, ReactNode } from 'react';
import { AuthService } from './AuthService';

// Create a new Query Client
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutes
      cacheTime: 30 * 60 * 1000, // 30 minutes
      retry: 3,
      refetchOnWindowFocus: true,
      refetchOnMount: true,
    },
  },
});

// Create a context for global access to auth methods
const AuthContext = createContext<{
  authService: AuthService | null;
}>({ authService: null });

// Provider component
export function ReactQueryAuthProvider({ children }: { children: ReactNode }) {
  const [authService] = useState(() => new AuthService());

  return (
    <AuthContext.Provider value={{ authService }}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </AuthContext.Provider>
  );
}

// Hook for accessing auth service
export function useAuthService() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuthService must be used within a ReactQueryAuthProvider');
  }
  return context.authService;
}

Auth Query Hook

// lib/hooks/useAuthQuery.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuthService } from '../auth/ReactQueryAuthProvider';
import * as SecureStore from 'expo-secure-store';

// Auth state query keys
export const AUTH_KEYS = {
  all: ['auth'] as const,
  current: () => [...AUTH_KEYS.all, 'current'] as const,
  profile: (pubkey: string) => [...AUTH_KEYS.all, 'profile', pubkey] as const,
};

// Hook for managing auth state
export function useAuthQuery() {
  const authService = useAuthService();
  const queryClient = useQueryClient();

  // Query for current auth state
  const authQuery = useQuery({
    queryKey: AUTH_KEYS.current(),
    queryFn: async () => {
      // Check for stored credentials
      const pubkey = await SecureStore.getItemAsync('nostr_pubkey');
      
      // If no credentials, return unauthenticated state
      if (!pubkey) {
        return { status: 'unauthenticated' };
      }
      
      // Initialize auth from stored credentials
      try {
        // For direct key login
        return await authService.getCurrentAuthState();
      } catch (error) {
        // Handle initialization error
        console.error('Auth initialization error:', error);
        
        // Clear any invalid credentials
        await SecureStore.deleteItemAsync('nostr_pubkey');
        
        return {
          status: 'error',
          error: error instanceof Error ? error : new Error(String(error))
        };
      }
    },
    // Auth doesn't automatically become stale
    staleTime: Infinity,
  });

  // Login mutation
  const loginMutation = useMutation({
    mutationFn: async (params: { 
      method: 'private_key' | 'amber' | 'ephemeral'; 
      privateKey?: string; 
    }) => {
      // Set auth state to authenticating first
      queryClient.setQueryData(AUTH_KEYS.current(), {
        status: 'authenticating',
        method: params.method
      });
      
      // Perform login based on method
      try {
        switch (params.method) {
          case 'private_key':
            if (!params.privateKey) throw new Error('Private key required');
            return await authService.loginWithPrivateKey(params.privateKey);
          case 'amber':
            return await authService.loginWithAmber();
          case 'ephemeral':
            return await authService.createEphemeralKey();
          default:
            throw new Error(`Unsupported auth method: ${params.method}`);
        }
      } catch (error) {
        throw error;
      }
    },
    onSuccess: (user) => {
      // Update auth state on success
      queryClient.setQueryData(AUTH_KEYS.current(), {
        status: 'authenticated',
        user,
        method: user.authMethod
      });
      
      // Invalidate auth-dependent queries
      queryClient.invalidateQueries({ queryKey: ['profile'] });
      queryClient.invalidateQueries({ queryKey: ['contacts'] });
      queryClient.invalidateQueries({ queryKey: ['relays'] });
    },
    onError: (error) => {
      // Set error state
      queryClient.setQueryData(AUTH_KEYS.current(), {
        status: 'error',
        error
      });
    }
  });

  // Logout mutation
  const logoutMutation = useMutation({
    mutationFn: async () => {
      // Perform logout
      await authService.logout();
    },
    onSuccess: () => {
      // Reset auth state
      queryClient.setQueryData(AUTH_KEYS.current(), {
        status: 'unauthenticated'
      });
      
      // Clear user-dependent queries
      queryClient.removeQueries({ queryKey: ['contacts'] });
      queryClient.removeQueries({ queryKey: ['feed'] });
      queryClient.removeQueries({ queryKey: ['profile'] });
    }
  });

  return {
    auth: authQuery.data,
    isLoading: authQuery.isLoading,
    isError: authQuery.isError,
    error: authQuery.error,
    login: loginMutation.mutate,
    logout: logoutMutation.mutate,
    isAuthenticating: loginMutation.isPending,
    isLoggingOut: logoutMutation.isPending,
  };
}

Amber Signer Integration

// lib/hooks/useAmberSignerWithQuery.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuthQuery, AUTH_KEYS } from './useAuthQuery';
import { NDKEvent } from '@nostr-dev-kit/ndk';

// Hook for Amber signing operations
export function useAmberSigner() {
  const { auth } = useAuthQuery();
  const queryClient = useQueryClient();

  // Mutation for signing events
  const signEventMutation = useMutation({
    mutationFn: async (event: NDKEvent) => {
      if (auth?.status !== 'authenticated') {
        throw new Error('Not authenticated');
      }
      
      // Update auth state to show signing in progress
      queryClient.setQueryData(AUTH_KEYS.current(), {
        ...auth,
        status: 'signing',
        operationCount: (auth.operationCount || 0) + 1,
        operations: [...(auth.operations || []), { 
          type: 'sign', 
          event, 
          timestamp: Date.now() 
        }]
      });
      
      try {
        // Use AmberSignerModule to sign the event
        const signedEvent = await AmberSignerModule.signEvent(
          JSON.stringify(event.rawEvent()), 
          event.pubkey
        );
        
        // Restore auth state after signing
        queryClient.setQueryData(AUTH_KEYS.current(), auth);
        
        return signedEvent;
      } catch (error) {
        // Update auth state with error
        queryClient.setQueryData(AUTH_KEYS.current(), {
          ...auth,
          status: 'authenticated',
          error
        });
        
        throw error;
      }
    }
  });

  return {
    signEvent: signEventMutation.mutate,
    isSigningEvent: signEventMutation.isPending,
    signingError: signEventMutation.error
  };
}

Authentication Component Updates

Components will need to be updated to use the new auth hooks consistently:

// Example Login Screen
function LoginScreen() {
  const { login, isAuthenticating, error } = useAuthQuery();
  const [privateKey, setPrivateKey] = useState('');

  // Handle login
  const handleLogin = () => {
    login({ method: 'private_key', privateKey });
  };

  // Handle Amber login
  const handleAmberLogin = () => {
    login({ method: 'amber' });
  };

  return (
    <View>
      {isAuthenticating ? (
        <ActivityIndicator />
      ) : (
        <>
          <TextInput
            value={privateKey}
            onChangeText={setPrivateKey}
            placeholder="Enter your private key"
            secureTextEntry
          />
          <Button onPress={handleLogin} title="Login with Private Key" />
          <Button onPress={handleAmberLogin} title="Login with Amber" />
          {error && <Text>{error.message}</Text>}
        </>
      )}
    </View>
  );
}

Data Fetching & Caching

Query Keys Structure

We'll implement a structured query key strategy for effective cache management:

// lib/queryKeys.ts
export const QUERY_KEYS = {
  auth: {
    all: ['auth'] as const,
    current: () => [...QUERY_KEYS.auth.all, 'current'] as const,
    profile: (pubkey: string) => [...QUERY_KEYS.auth.all, 'profile', pubkey] as const,
  },
  contacts: {
    all: ['contacts'] as const,
    list: (pubkey: string) => [...QUERY_KEYS.contacts.all, 'list', pubkey] as const,
  },
  feed: {
    all: ['feed'] as const,
    type: (type: string) => [...QUERY_KEYS.feed.all, type] as const,
    user: (type: string, pubkey: string) => [...QUERY_KEYS.feed.type(type), pubkey] as const,
  },
  workouts: {
    all: ['workouts'] as const,
    list: (filters?: any) => [...QUERY_KEYS.workouts.all, 'list', filters] as const,
    detail: (id: string) => [...QUERY_KEYS.workouts.all, 'detail', id] as const,
    history: (pubkey: string) => [...QUERY_KEYS.workouts.all, 'history', pubkey] as const,
  },
  templates: {
    all: ['templates'] as const,
    list: (filters?: any) => [...QUERY_KEYS.templates.all, 'list', filters] as const,
    detail: (id: string) => [...QUERY_KEYS.templates.all, 'detail', id] as const,
  },
  exercises: {
    all: ['exercises'] as const,
    list: (filters?: any) => [...QUERY_KEYS.exercises.all, 'list', filters] as const,
    detail: (id: string) => [...QUERY_KEYS.exercises.all, 'detail', id] as const,
  },
  powr_packs: {
    all: ['powr_packs'] as const,
    list: () => [...QUERY_KEYS.powr_packs.all, 'list'] as const,
    detail: (id: string) => [...QUERY_KEYS.powr_packs.all, 'detail', id] as const,
  },
  publication_queue: {
    all: ['publication_queue'] as const,
    list: () => [...QUERY_KEYS.publication_queue.all, 'list'] as const,
  },
  relays: {
    all: ['relays'] as const,
    list: () => [...QUERY_KEYS.relays.all, 'list'] as const,
    status: () => [...QUERY_KEYS.relays.all, 'status'] as const,
  },
};

Sample Data Access Hooks

Social Feed

// lib/hooks/useSocialFeedWithQuery.ts
import { useQuery } from '@tanstack/react-query';
import { useAuthQuery } from './useAuthQuery';
import { QUERY_KEYS } from '../queryKeys';
import { SocialFeedCache } from '../db/services/SocialFeedCache';
import { SocialFeedService } from '../social/socialFeedService';
import { useConnectivity } from './useConnectivity';

export function useSocialFeedWithQuery({
  feedType = 'global',
  authors = [],
  limit = 30
}) {
  const { auth } = useAuthQuery();
  const { isOnline } = useConnectivity();
  const isAuthenticated = auth?.status === 'authenticated';

  return useQuery({
    queryKey: QUERY_KEYS.feed.type(feedType),
    queryFn: async () => {
      // First check cache
      const cachedEvents = await SocialFeedCache.getFeedEvents(feedType, limit);
      
      // If offline, return cached data only
      if (!isOnline) {
        return cachedEvents;
      }
      
      // If online, fetch fresh data
      try {
        const socialService = new SocialFeedService();
        
        // Different behavior based on feed type
        let events;
        
        switch (feedType) {
          case 'following':
            // Only fetch following feed if authenticated
            if (!isAuthenticated) return cachedEvents;
            events = await socialService.getFollowingFeed(auth.user.pubkey, limit);
            break;
          case 'global':
            events = await socialService.getGlobalFeed(limit);
            break;
          case 'powr':
            events = await socialService.getPOWRFeed(limit);
            break;
          default:
            events = await socialService.getGlobalFeed(limit);
        }
        
        // Cache the new events
        await SocialFeedCache.cacheFeedEvents(feedType, events);
        
        return events;
      } catch (error) {
        console.error(`Error fetching ${feedType} feed:`, error);
        // Fall back to cache on error
        return cachedEvents;
      }
    },
    // Only run query for following feed if authenticated
    enabled: feedType !== 'following' || isAuthenticated,
    // Data refetching strategies
    staleTime: 60 * 1000, // 1 minute
    refetchOnWindowFocus: true,
    refetchInterval: isOnline ? 5 * 60 * 1000 : false, // 5 minutes if online
    // Provide last cached result while refetching
    placeholderData: keepPreviousData,
  });
}

Workout History

// lib/hooks/useWorkoutHistoryWithQuery.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { QUERY_KEYS } from '../queryKeys';
import { useAuthQuery } from './useAuthQuery';
import { useConnectivity } from './useConnectivity';
import { WorkoutService } from '../db/services/WorkoutService';
import { NostrWorkoutService } from '../db/services/NostrWorkoutService';
import { mergeWorkouts } from '../utils/workout';

export function useWorkoutHistoryWithQuery(options = {}) {
  const { auth } = useAuthQuery();
  const { isOnline } = useConnectivity();
  const queryClient = useQueryClient();
  const isAuthenticated = auth?.status === 'authenticated';
  const pubkey = auth?.user?.pubkey;

  // Query for workout history
  const workoutHistoryQuery = useQuery({
    queryKey: QUERY_KEYS.workouts.history(pubkey || 'anonymous'),
    queryFn: async () => {
      // Always fetch local workouts
      const localWorkouts = await WorkoutService.getWorkoutHistory();
      
      // If not authenticated or offline, return only local
      if (!isAuthenticated || !isOnline) {
        return localWorkouts;
      }
      
      try {
        // Fetch Nostr workouts
        const nostrWorkouts = await NostrWorkoutService.getWorkoutHistory(pubkey);
        
        // Merge local and Nostr workouts, removing duplicates and sorting by date
        const mergedWorkouts = mergeWorkouts(localWorkouts, nostrWorkouts);
        
        return mergedWorkouts;
      } catch (error) {
        console.error('Error fetching Nostr workouts:', error);
        // Fall back to local on error
        return localWorkouts;
      }
    },
    // Only enabled if we have a valid pubkey for authenticated users
    enabled: !isAuthenticated || !!pubkey,
    staleTime: 5 * 60 * 1000, // 5 minutes
    placeholderData: keepPreviousData,
  });

  // Add workout mutation
  const addWorkoutMutation = useMutation({
    mutationFn: async (workout) => {
      // Always save to local database first
      const savedWorkout = await WorkoutService.saveWorkout(workout);
      
      // If authenticated and online, also publish to Nostr
      if (isAuthenticated && isOnline) {
        try {
          await NostrWorkoutService.publishWorkout(savedWorkout);
        } catch (error) {
          // If publishing fails, queue for later
          console.error('Error publishing workout to Nostr:', error);
          await PublicationQueueService.queueWorkout(savedWorkout);
        }
      } else if (isAuthenticated) {
        // If offline but authenticated, queue for later
        await PublicationQueueService.queueWorkout(savedWorkout);
      }
      
      return savedWorkout;
    },
    onSuccess: (savedWorkout) => {
      // Update queries that might have this workout
      queryClient.invalidateQueries({
        queryKey: QUERY_KEYS.workouts.history(pubkey || 'anonymous'),
      });
    }
  });

  return {
    workouts: workoutHistoryQuery.data || [],
    isLoading: workoutHistoryQuery.isLoading,
    isError: workoutHistoryQuery.isError,
    error: workoutHistoryQuery.error,
    addWorkout: addWorkoutMutation.mutate,
    isAddingWorkout: addWorkoutMutation.isPending,
  };
}

Exercise Library

// lib/hooks/useExercisesWithQuery.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { QUERY_KEYS } from '../queryKeys';
import { ExerciseService } from '../db/services/ExerciseService';
import { useAuthQuery } from './useAuthQuery';
import { useConnectivity } from './useConnectivity';

export function useExercisesWithQuery(filters = {}) {
  const queryClient = useQueryClient();
  const { auth } = useAuthQuery();
  const { isOnline } = useConnectivity();
  const isAuthenticated = auth?.status === 'authenticated';

  // Query for exercise list
  const exercisesQuery = useQuery({
    queryKey: QUERY_KEYS.exercises.list(filters),
    queryFn: async () => {
      // Fetch from SQLite
      return ExerciseService.getExercises(filters);
    },
    staleTime: 10 * 60 * 1000, // 10 minutes
  });

  // Mutation for adding an exercise
  const addExerciseMutation = useMutation({
    mutationFn: async (exercise) => {
      // Save to SQLite
      const savedExercise = await ExerciseService.saveExercise(exercise);
      
      // If authenticated and online, also publish to Nostr
      if (isAuthenticated && isOnline) {
        try {
          await PublishExerciseToNostr(savedExercise);
        } catch (error) {
          // If publishing fails, queue for later
          console.error('Error publishing exercise to Nostr:', error);
          await PublicationQueueService.queueExercise(savedExercise);
        }
      } else if (isAuthenticated) {
        // If offline but authenticated, queue for later
        await PublicationQueueService.queueExercise(savedExercise);
      }
      
      return savedExercise;
    },
    onSuccess: (savedExercise) => {
      // Update exercise list
      queryClient.invalidateQueries({
        queryKey: QUERY_KEYS.exercises.list(),
      });
    }
  });

  return {
    exercises: exercisesQuery.data || [],
    isLoading: exercisesQuery.isLoading,
    isError: exercisesQuery.isError,
    error: exercisesQuery.error,
    addExercise: addExerciseMutation.mutate,
    isAddingExercise: addExerciseMutation.isPending,
  };
}

Offline Support & Publication Queue

Enhanced Publication Queue

// lib/hooks/usePublicationQueueWithQuery.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { QUERY_KEYS } from '../queryKeys';
import { PublicationQueueService } from '../db/services/PublicationQueueService';
import { useAuthQuery } from './useAuthQuery';
import { useConnectivity } from './useConnectivity';

export function usePublicationQueueWithQuery() {
  const queryClient = useQueryClient();
  const { auth } = useAuthQuery();
  const { isOnline } = useConnectivity();
  const isAuthenticated = auth?.status === 'authenticated';

  // Query for queue items
  const queueQuery = useQuery({
    queryKey: QUERY_KEYS.publication_queue.list(),
    queryFn: async () => {
      return PublicationQueueService.getQueueItems();
    },
    // Refresh every minute to check for new items
    refetchInterval: 60 * 1000,
  });

  // Process a single item
  const processItemMutation = useMutation({
    mutationFn: async (item) => {
      if (!isOnline || !isAuthenticated) {
        throw new Error('Cannot process: offline or not authenticated');
      }

      // Process based on item type
      switch (item.type) {
        case 'workout':
          await NostrWorkoutService.publishWorkout(item.data);
          break;
        case 'exercise':
          await PublishExerciseToNostr(item.data);
          break;
        // Other types...
        default:
          throw new Error(`Unknown queue item type: ${item.type}`);
      }

      // Remove from queue if successful
      await PublicationQueueService.removeItem(item.id);
      
      return { success: true, item };
    },
    onSuccess: (result, item) => {
      // Invalidate related queries
      switch (item.type) {
        case 'workout':
          queryClient.invalidateQueries({
            queryKey: QUERY_KEYS.workouts.all,
          });
          break;
        case 'exercise':
          queryClient.invalidateQueries({
            queryKey: QUERY_KEYS.exercises.all,
          });
          break;
        // Other types...
      }
      
      // Invalidate the queue
      queryClient.invalidateQueries({
        queryKey: QUERY_KEYS.publication_queue.list(),
      });
    }
  });

  // Process all eligible items
  const processQueueMutation = useMutation({
    mutationFn: async () => {
      if (!isOnline || !isAuthenticated) {
        return { processed: 0, skipped: queueQuery.data?.length || 0 };
      }

      const items = queueQuery.data || [];
      
      // Process each item
      const results = await Promise.allSettled(
        items.map(item => processItemMutation.mutateAsync(item))
      );
      
      // Count successes and failures
      const successes = results.filter(r => r.status === 'fulfilled').length;
      const failures = results.filter(r => r.status === 'rejected').length;
      
      return {
        processed: successes,
        failed: failures,
        total: items.length
      };
    }
  });

  // Auto-process queue when connectivity changes
  useEffect(() => {
    if (isOnline && isAuthenticated && queueQuery.data?.length > 0) {
      processQueueMutation.mutate();
    }
  }, [isOnline, isAuthenticated, queueQuery.data?.length]);

  return {
    queueItems: queueQuery.data || [],
    isLoading: queueQuery.isLoading,
    processItem: processItemMutation.mutate,
    processQueue: processQueueMutation.mutate,
    isProcessing: processItemMutation.isPending || processQueueMutation.isPending,
    processStats: processQueueMutation.data,
  };
}

Connectivity Monitoring

// lib/hooks/useConnectivityWithQuery.ts
import { useQuery } from '@tanstack/react-query';
import NetInfo from '@react-native-community/netinfo';
import { QUERY_KEYS } from '../queryKeys';

export function useConnectivityWithQuery() {
  return useQuery({
    queryKey: ['connectivity'],
    queryFn: async () => {
      const state = await NetInfo.fetch();
      return {
        isOnline: state.isConnected && state.isInternetReachable,
        type: state.type,
        details: state.details
      };
    },
    // Refetch connectivity status frequently
    refetchInterval: 30 * 1000, // 30 seconds
    // Always refetch on window focus
    refetchOnWindowFocus: true,
    // Start with offline assumption for safety
    placeholderData: { isOnline: false, type: 'unknown', details: null },
  });
}

// Add a higher-order component to automatically retry data fetching when connectivity is restored
export function withConnectivityRetry(Component) {
  return function ConnectivityAwareComponent(props) {
    const { data: connectivity } = useConnectivityWithQuery();
    const queryClient = useQueryClient();
    
    // When connectivity is restored, invalidate certain queries
    useEffect(() => {
      if (connectivity?.isOnline) {
        // Invalidate stale queries
        queryClient.invalidateQueries({ type: 'all', stale: true });
      }
    }, [connectivity?.isOnline, queryClient]);
    
    return <Component {...props} connectivity={connectivity} />;
  };
}

Error Handling

Proper error handling is critical for a robust user experience, especially in a fitness app with offline capabilities. React Query provides excellent tools for error handling that we'll leverage.

NDK-Specific Error Types

// lib/errors/NDKErrors.ts
export enum NDKErrorType {
  NETWORK = 'network',
  RELAY_REJECTION = 'relay_rejection',
  SIGNING_FAILURE = 'signing_failure',
  VALIDATION_ERROR = 'validation_error',
  TIMEOUT = 'timeout',
  UNKNOWN = 'unknown'
}

export class NDKQueryError extends Error {
  type: NDKErrorType;
  relayUrl?: string;
  recoverable: boolean;
  retryAfter?: number;
  
  constructor(message: string, options: {
    type: NDKErrorType;
    relayUrl?: string;
    recoverable?: boolean;
    retryAfter?: number;
  }) {
    super(message);
    this.name = 'NDKQueryError';
    this.type = options.type;
    this.relayUrl = options.relayUrl;
    this.recoverable = options.recoverable ?? true;
    this.retryAfter = options.retryAfter;
  }
}

// Helper to categorize errors
export function handleNDKError(error: any): NDKQueryError {
  // Network errors
  if (
    error.message?.includes('disconnected') || 
    error.message?.includes('failed to fetch') ||
    error.message?.includes('network')
  ) {
    return new NDKQueryError('Network connection issue', {
      type: NDKErrorType.NETWORK,
      recoverable: true,
      retryAfter: 5000 // 5 seconds
    });
  }
  
  // Relay rejection errors
  if (error.message?.includes('rejected') || error.code === 'rejected') {
    return new NDKQueryError(`Relay rejected event: ${error.message}`, {
      type: NDKErrorType.RELAY_REJECTION,
      relayUrl: error.relay?.url,
      recoverable: false
    });
  }
  
  // Signing errors (e.g., Amber)
  if (
    error.message?.includes('signing') || 
    error.message?.includes('permission denied') || 
    error.message?.includes('cancelled')
  ) {
    return new NDKQueryError(`Failed to sign event: ${error.message}`, {
      type: NDKErrorType.SIGNING_FAILURE,
      recoverable: false
    });
  }
  
  // Timeout errors
  if (error.message?.includes('timeout')) {
    return new NDKQueryError(`Operation timed out: ${error.message}`, {
      type: NDKErrorType.TIMEOUT,
      recoverable: true,
      retryAfter: 10000 // 10 seconds
    });
  }
  
  // Default to unknown error
  return new NDKQueryError(`Unknown NDK error: ${error.message || 'No details'}`, {
    type: NDKErrorType.UNKNOWN,
    recoverable: true
  });
}

Centralized Error Handling with QueryClient

// lib/queryClient.ts
import { QueryClient, QueryCache } from '@tanstack/react-query';
import { showErrorToast } from '../utils/toast';
import { NDKQueryError, NDKErrorType } from './errors/NDKErrors';

// Create a custom query client with centralized error handling
export function createQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: (failureCount, error) => {
          // Custom retry logic based on error type
          if (error instanceof NDKQueryError) {
            // Don't retry unrecoverable errors
            if (!error.recoverable) return false;
            
            // Limit retry count based on error type
            switch (error.type) {
              case NDKErrorType.NETWORK:
                return failureCount < 5; // More retries for network issues
              case NDKErrorType.TIMEOUT:
                return failureCount < 3; // Reasonable retries for timeouts
              default:
                return failureCount < 2; // Limited retries for other errors
            }
          }
          
          // Default retry logic
          return failureCount < 3;
        },
        retryDelay: (attemptIndex, error) => {
          // Use recommended retry delay if available
          if (error instanceof NDKQueryError && error.retryAfter) {
            return error.retryAfter;
          }
          
          // Default exponential backoff with jitter
          const baseDelay = 1000; // 1 second
          const maxDelay = 30000; // 30 seconds
          const exponentialDelay = Math.min(
            baseDelay * (2 ** attemptIndex), 
            maxDelay
          );
          
          // Add jitter (up to 25%)
          const jitter = Math.random() * 0.25 * exponentialDelay;
          return exponentialDelay + jitter;
        }
      }
    },
    queryCache: new QueryCache({
      onError: (error, query) => {
        // Only show meaningful errors to the user
        if (shouldShowToUser(error, query)) {
          const errorTitle = getErrorTitle(error);
          const errorMessage = getErrorMessage(error);
          
          showErrorToast({
            title: errorTitle,
            message: errorMessage,
            duration: 3000
          });
        }
        
        // Log all errors for debugging
        console.error(`[QueryError] ${query.queryKey}:`, error);
      }
    })
  });
}

// Helper to determine if error should be shown to user
function shouldShowToUser(error: any, query: any): boolean {
  // Don't show network errors during background updates
  if (
    error instanceof NDKQueryError && 
    error.type === NDKErrorType.NETWORK &&
    query.state.fetchStatus === 'fetching' &&
    query.state.data !== undefined
  ) {
    return false;
  }
  
  // Don't show errors for certain query types
  if (
    query.queryKey[0] === 'connectivity' || 
    query.queryKey[0] === 'publication_queue'
  ) {
    return false;
  }
  
  return true;
}

// Helper to get a user-friendly error title
function getErrorTitle(error: any): string {
  if (error instanceof NDKQueryError) {
    switch (error.type) {
      case NDKErrorType.NETWORK:
        return 'Connection Issue';
      case NDKErrorType.RELAY_REJECTION:
        return 'Request Rejected';
      case NDKErrorType.SIGNING_FAILURE:
        return 'Authentication Error';
      case NDKErrorType.TIMEOUT:
        return 'Request Timeout';
      default:
        return 'Unexpected Error';
    }
  }
  
  return 'Error';
}

// Helper to get a user-friendly error message
function getErrorMessage(error: any): string {
  if (error instanceof NDKQueryError) {
    switch (error.type) {
      case NDKErrorType.NETWORK:
        return 'Unable to connect to the network. Your changes will be saved locally and synced when connection is restored.';
      case NDKErrorType.RELAY_REJECTION:
        return 'The server rejected your request. Please try again later.';
      case NDKErrorType.SIGNING_FAILURE:
        return 'Unable to sign the data. Please check your authentication and try again.';
      case NDKErrorType.TIMEOUT:
        return 'The request took too long to complete. Please try again.';
      default:
        return error.message || 'Something went wrong. Please try again.';
    }
  }
  
  return error.message || 'An unknown error occurred.';
}

Error Boundary for React Query

// components/QueryErrorBoundary.tsx
import React from 'react';
import { Text, View, Button } from 'react-native';
import { useQueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';

export function QueryErrorBoundary({ children }) {
  const { reset } = useQueryErrorResetBoundary();
  
  return (
    <ErrorBoundary
      onReset={reset}
      fallbackRender={({ error, resetErrorBoundary }) => (
        <View className="flex-1 items-center justify-center p-4">
          <Text className="text-xl font-bold mb-2">Something went wrong</Text>
          <Text className="text-center mb-4">{error.message}</Text>
          <Button 
            title="Try again" 
            onPress={resetErrorBoundary} 
          />
        </View>
      )}
    >
      {children}
    </ErrorBoundary>
  );
}

Conflict Resolution

A key challenge in any offline-first application is handling conflicts between local and remote data. This is especially important for a fitness app with Nostr integration where data can be modified in multiple locations.

Conflict Types

In POWR, we need to handle several types of conflicts:

  1. Workout Records Conflicts: Local workout records vs. published Nostr events
  2. Template Conflicts: Changes to templates that exist both locally and remotely
  3. Exercise Conflicts: Modifications to exercises that may be referenced by templates
  4. User Content Conflicts: Conflicts when importing content created by other users

Conflict Resolution Strategies

// lib/utils/conflictResolution.ts
import { Workout, Exercise, Template } from '../types';

// Merge strategies
export type MergeStrategy = 
  | 'local-wins' 
  | 'remote-wins' 
  | 'newest-wins' 
  | 'manual';

// Interface for entities that can be merged
export interface Mergeable {
  id: string;
  updatedAt?: number; // timestamp
  createdAt?: number;
  version?: number;
  source?: 'local' | 'remote';
  nostrEvent?: any; // Nostr event data
}

// Generic merge function
export function mergeEntities<T extends Mergeable>(
  local: T | null,
  remote: T | null,
  strategy: MergeStrategy = 'newest-wins'
): { 
  result: T | null;
  conflict: boolean;
  winner: 'local' | 'remote' | 'none'; 
} {
  // If only one exists, return it
  if (!local) return { result: remote, conflict: false, winner: remote ? 'remote' : 'none' };
  if (!remote) return { result: local, conflict: false, winner: 'local' };
  
  // Check if they're identical (e.g., same version or same content)
  if (local.version === remote.version || areEntitiesIdentical(local, remote)) {
    return { result: local, conflict: false, winner: 'local' };
  }
  
  // Detect conflict
  const conflict = true;
  
  // Apply merge strategy
  switch (strategy) {
    case 'local-wins':
      return { result: local, conflict, winner: 'local' };
      
    case 'remote-wins':
      return { result: remote, conflict, winner: 'remote' };
      
    case 'newest-wins':
      // Use updatedAt to determine which is newer
      const localTime = local.updatedAt || local.createdAt || 0;
      const remoteTime = remote.updatedAt || remote.createdAt || 0;
      
      if (localTime > remoteTime) {
        return { result: local, conflict, winner: 'local' };
      } else if (remoteTime > localTime) {
        return { result: remote, conflict, winner: 'remote' };
      } else {
        // If timestamps match, prefer remote for consistency
        return { result: remote, conflict, winner: 'remote' };
      }
      
    case 'manual':
      // Return both for manual resolution
      throw new ConflictError(local, remote);
      
    default:
      // Default to newest-wins
      return mergeEntities(local, remote, 'newest-wins');
  }
}

// Custom error for conflicts that require manual resolution
export class ConflictError extends Error {
  local: Mergeable;
  remote: Mergeable;
  
  constructor(local: Mergeable, remote: Mergeable) {
    super('Data conflict detected');
    this.name = 'ConflictError';
    this.local = local;
    this.remote = remote;
  }
}

// Helper to check if entities are identical in content
function areEntitiesIdentical(a: Mergeable, b: Mergeable): boolean {
  // Basic check for different IDs
  if (a.id !== b.id) return false;
  
  // For Nostr events, compare the raw event content
  if (a.nostrEvent && b.nostrEvent) {
    return a.nostrEvent.content === b.nostrEvent.content;
  }
  
  // Basic comparison for other entities
  // This is a simple check and should be enhanced for specific entity types
  const aKeys = Object.keys(a).filter(k => !['source', 'nostrEvent'].includes(k));
  const bKeys = Object.keys(b).filter(k => !['source', 'nostrEvent'].includes(k));
  
  if (aKeys.length !== bKeys.length) return false;
  
  return aKeys.every(key => {
    // Skip certain fields
    if (['updatedAt', 'createdAt', 'version'].includes(key)) return true;
    
    return JSON.stringify(a[key]) === JSON.stringify(b[key]);
  });
}

// Workout-specific merge function
export function mergeWorkouts(
  local: Workout | null, 
  remote: Workout | null, 
  strategy: MergeStrategy = 'newest-wins'
): Workout | null {
  const result = mergeEntities(local, remote, strategy);
  
  if (!result.result || !result.conflict) {
    return result.result;
  }
  
  // If there's a conflict, we need to merge the workout details
  const base = result.winner === 'local' ? local! : remote!;
  const other = result.winner === 'local' ? remote! : local!;
  
  // Start with the winning workout
  const merged = { ...base };
  
  // Merge exercises (this would need a more sophisticated algorithm in practice)
  if (base.exercises && other.exercises) {
    // Simple approach: use exercises from winning workout
    // A more complex merge would match exercises by ID and merge their sets
    merged.exercises = base.exercises;
  }
  
  return merged;
}

// Exercise-specific merge
export function mergeExercises(
  local: Exercise | null, 
  remote: Exercise | null, 
  strategy: MergeStrategy = 'newest-wins'
): Exercise | null {
  return mergeEntities(local, remote, strategy).result;
}

// Template-specific merge
export function mergeTemplates(
  local: Template | null, 
  remote: Template | null, 
  strategy: MergeStrategy = 'newest-wins'
): Template | null {
  const result = mergeEntities(local, remote, strategy);
  
  if (!result.result || !result.conflict) {
    return result.result;
  }
  
  // If there's a conflict, we need to merge the template details
  const base = result.winner === 'local' ? local! : remote!;
  const other = result.winner === 'local' ? remote! : local!;
  
  // Start with the winning template
  const merged = { ...base };
  
  // Merge exercises (similar to workout merge)
  if (base.exercises && other.exercises) {
    merged.exercises = base.exercises;
  }
  
  return merged;
}

Applying Conflict Resolution in Queries

// Using conflict resolution in workout history query
export function useWorkoutHistoryWithConflictResolution(options = {}) {
  const { auth } = useAuthQuery();
  const { isOnline } = useConnectivityWithQuery();
  const isAuthenticated = auth?.status === 'authenticated';
  const pubkey = auth?.user?.pubkey;
  
  // State for tracking conflicts
  const [conflicts, setConflicts] = useState<{
    [id: string]: { local: Workout; remote: Workout; resolved: boolean }
  }>({});
  
  // Query for workout history
  const workoutHistoryQuery = useQuery({
    queryKey: QUERY_KEYS.workouts.history(pubkey || 'anonymous'),
    queryFn: async () => {
      // Get local workouts
      const localWorkouts = await WorkoutService.getWorkoutHistory();
      
      // If not authenticated or offline, return only local
      if (!isAuthenticated || !isOnline) {
        return localWorkouts;
      }
      
      try {
        // Get Nostr workouts
        const nostrWorkouts = await NostrWorkoutService.getWorkoutHistory(pubkey);
        
        // Create maps for efficient lookup
        const localWorkoutMap = Object.fromEntries(
          localWorkouts.map(w => [w.id, { ...w, source: 'local' }])
        );
        
        const remoteWorkoutMap = Object.fromEntries(
          nostrWorkouts.map(w => [w.id, { ...w, source: 'remote' }])
        );
        
        // Combine all workout IDs
        const allIds = [...new Set([
          ...Object.keys(localWorkoutMap),
          ...Object.keys(remoteWorkoutMap)
        ])];
        
        // Track new conflicts
        const newConflicts = {};
        
        // Merge workouts
        const mergedWorkouts = allIds.map(id => {
          const local = localWorkoutMap[id] || null;
          const remote = remoteWorkoutMap[id] || null;
          
          try {
            // Attempt to merge
            const merged = mergeWorkouts(local, remote);
            return merged;
          } catch (error) {
            if (error instanceof ConflictError) {
              // Store conflict for manual resolution
              newConflicts[id] = {
                local: error.local as Workout,
                remote: error.remote as Workout,
                resolved: false
              };
              
              // For now, return local version
              return local;
            }
            
            // For other errors, prefer local
            console.error(`Error merging workout ${id}:`, error);
            return local || remote;
          }
        }).filter(Boolean);
        
        // Update conflicts
        if (Object.keys(newConflicts).length > 0) {
          setConflicts(prev => ({
            ...prev,
            ...newConflicts
          }));
        }
        
        return mergedWorkouts;
      } catch (error) {
        console.error('Error fetching Nostr workouts:', error);
        return localWorkouts;
      }
    }
  });
  
  // Function to resolve a conflict manually
  const resolveConflict = useCallback((id: string, winner: 'local' | 'remote') => {
    const conflict = conflicts[id];
    if (!conflict) return;
    
    // Get the winning workout
    const workout = winner === 'local' ? conflict.local : conflict.remote;
    
    // Save the winning workout
    WorkoutService.saveWorkout(workout)
      .then(() => {
        // Mark conflict as resolved
        setConflicts(prev => ({
          ...prev,
          [id]: { ...prev[id], resolved: true }
        }));
        
        // Refetch workout history
        workoutHistoryQuery.refetch();
      })
      .catch(error => {
        console.error('Error resolving conflict:', error);
      });
  }, [conflicts, workoutHistoryQuery]);
  
  return {
    workouts: workoutHistoryQuery.data || [],
    isLoading: workoutHistoryQuery.isLoading,
    conflicts: Object.entries(conflicts)
      .filter(([_, conflict]) => !conflict.resolved)
      .map(([id, conflict]) => ({ id, ...conflict })),
    resolveConflict,
  };
}

Implementation Roadmap

A phased approach to integrating React Query will help manage complexity and minimize disruption:

Phase 1: Core Infrastructure (Weeks 1-2)

  1. Setup React Query Foundation

    • Install React Query package
    • Create QueryClientProvider setup
    • Add basic error handling
  2. Authentication Integration

    • Implement useAuthQuery hook
    • Create centralized authentication service
    • Update NDK signer integration
  3. Build Query Key Structure

    • Establish query key naming conventions
    • Create query key utilities
    • Document key structure

Phase 2: Social & Profile Features (Weeks 3-4)

  1. Profile Integration

    • Move profile fetching to React Query
    • Implement caching for profile data
    • Fix profile-related hook ordering issues
  2. Social Feed Integration

    • Convert useSocialFeed to React Query
    • Implement better cache management for feed data
    • Fix following feed issues
  3. Contact List Integration

    • Update useContactList with React Query
    • Enhance contact synchronization
    • Fix contact-related hook ordering issues

Phase 3: Workout & Exercise Features (Weeks 5-6)

  1. Exercise Library Integration

    • Convert exercise library to React Query
    • Implement conflict resolution for exercises
    • Update exercise caching strategy
  2. Template Management

    • Convert template hooks to React Query
    • Add conflict resolution for templates
    • Update template-related components
  3. Workout History Integration

    • Update workout history with React Query
    • Implement workout conflict resolution
    • Enhance workout synchronization

Phase 4: System-Wide Integration (Weeks 7-8)

  1. Publication Queue Enhancement

    • Convert publication queue to React Query
    • Implement background processing
    • Add better retry strategies
  2. Offline Support Refinement

    • Enhance connectivity monitoring
    • Add automatic synchronization
    • Improve error recovery
  3. Testing & Refinement

    • Test authentication transitions
    • Verify offline behavior
    • Optimize performance

Migration Approach

For each component being migrated:

  1. Create new React Query hook alongside existing hook
  2. Update component to use both hooks temporarily
  3. Once the new hook is stable, remove the old hook
  4. Fix any dependencies or issues

This allows for gradual transition with minimal disruption.

API Reference

Authentication Hooks

  • useAuthQuery(): Primary hook for authentication state
  • useAmberSigner(): Hook for Amber signer integration
  • useAuthStatus(): Hook for checking authentication status

Data Access Hooks

  • useSocialFeedWithQuery(): Hook for social feed access
  • useWorkoutHistoryWithQuery(): Hook for workout history
  • useExercisesWithQuery(): Hook for exercise library
  • useTemplatesWithQuery(): Hook for template management
  • usePOWRPacksWithQuery(): Hook for POWR packs integration

System Hooks

  • useConnectivityWithQuery(): Hook for monitoring connectivity
  • usePublicationQueueWithQuery(): Hook for publication queue
  • useRelaysWithQuery(): Hook for relay management

Utility Hooks

  • useOptimisticMutation(): Helper for optimistic updates
  • useConflictResolution(): Hook for managing conflicts
  • useAuthCompatibleQuery(): Hook that's safe for auth state transitions

Migration Guide

Component Migration Example

Before:

function FollowingScreen() {
  const { isAuthenticated, currentUser } = useNDKCurrentUser();
  
  // Only execute if authenticated
  const { contacts } = isAuthenticated 
    ? useContactList(currentUser?.pubkey) 
    : { contacts: [] };
  
  // Only execute if authenticated and has contacts
  const { 
    feedItems, 
    loading, 
    refresh 
  } = isAuthenticated && contacts.length > 0 
    ? useSocialFeed({ feedType: 'following', authors: contacts })
    : { feedItems: [], loading: false, refresh: () => Promise.resolve() };
  
  // Early return for unauthenticated state
  if (!isAuthenticated) {
    return <NostrLoginPrompt />;
  }
  
  return (
    <FlatList
      data={feedItems}
      // Other props
    />
  );
}

After:

function FollowingScreen() {
  const { auth } = useAuthQuery();
  const isAuthenticated = auth?.status === 'authenticated';
  
  // Always execute, but parameters depend on auth state
  const { contacts } = useContactsWithQuery({
    pubkey: auth?.user?.pubkey || '',
    enabled: isAuthenticated
  });
  
  // Always execute, but parameters depend on auth state and contacts
  const { 
    feedItems, 
    isLoading, 
    refetch 
  } = useSocialFeedWithQuery({
    feedType: 'following',
    authors: contacts || [],
    enabled: isAuthenticated && (contacts?.length > 0)
  });
  
  // Render different UI based on state, but always call the same hooks
  if (!isAuthenticated) {
    return <NostrLoginPrompt />;
  }
  
  return (
    <FlatList
      data={feedItems}
      // Other props
    />
  );
}

Best Practices

  1. Always call hooks in the same order: Never conditionally call hooks
  2. Use the enabled parameter: Control when queries execute
  3. Provide fallback data: Use placeholderData for consistent UI
  4. Handle authentication in query functions: Not in component conditionals
  5. Use QueryErrorBoundary: Wrap components for clean error handling
  6. Standardize error handling: Use the NDKErrors system
  7. Implement optimistic updates: For better user experience
  8. Cache invalidation: Properly invalidate dependent queries
  9. Prefer mutation for state changes: Use mutations for all data modifications

By following these guidelines, we can ensure a smooth migration to React Query while fixing the critical issues in our current authentication system.