UTA DevHub

Server State Management

Comprehensive guide for managing server state with TanStack Query in our React Native application

Server State Management

Overview

This document outlines our approach to managing server state - data that originates from external APIs and requires caching, synchronization, and complex loading states. Our architecture implements TanStack Query (React Query) as the dedicated solution for all server state management, following our application's Golden Rule: "Server state belongs in TanStack Query, not in Zustand or Redux". This approach optimizes for performance, developer experience, and data consistency across the application.

Purpose & Scope

  • Target Audience: React Native developers implementing features that require API data fetching, caching, or state synchronization
  • Problems Addressed: API data management, caching strategies, loading/error states, and data synchronization challenges
  • Scope Boundaries: Covers TanStack Query implementation, query patterns, mutation strategies, and caching approaches but does not cover API service implementation details or authentication token management

Core Components/Architecture

Component Types

ComponentResponsibilityImplementation
Query HooksData fetching and cachingFeature-specific custom hooks
Mutation HooksData updating and optimistic updatesFeature-specific custom hooks
Query ClientCentral management and configurationGlobally configured instance
Query CacheIn-memory data storageManaged by Query Client
Query KeysCache identification and organizationStructured array patterns

When to Use Server State

TanStack Query should be used for any data that meets these criteria:

  • (Do ✅) Use for data fetched from external APIs or backend services
  • (Do ✅) Use for data that requires caching and deduplication
  • (Do ✅) Use for data with complex loading or error states
  • (Do ✅) Use for data that needs synchronization across components
  • (Do ✅) Use for real-time data that requires polling or refetching

Design Principles

Core Architectural Principles

  • (Do ✅) Keep server state separate from client state

    • Server state should be managed by TanStack Query
    • Client state should be managed by Zustand
    • Mixing these concerns leads to unnecessary complexity
  • (Do ✅) Create focused, reusable query hooks

    • Each query hook should serve a specific data need
    • Hooks should be feature-specific and located in the appropriate feature directory
    • Abstract complex query logic into custom hooks
  • (Don't ❌) Store server data in Zustand or React context

    • Avoid creating duplicate copies of server state
    • Let TanStack Query handle caching and synchronization

Data Access Patterns

PatternUsed ForImplementation
Basic QueriesSimple data fetchinguseQuery with query keys
Dependent QueriesData that depends on other queriesuseQuery with enabled option
Paginated QueriesLists with paginationuseQuery with pagination params
Infinite QueriesEndless scrolling listsuseInfiniteQuery with getNextPageParam
MutationsCreating, updating, deleting datauseMutation with callbacks
Optimistic UpdatesImmediate UI feedbackuseMutation with onMutate

Query Key Strategies

Query keys should follow a consistent structure:

// Pattern for detail queries
['entity', 'detail', entityId, ...params]
 
// Pattern for list queries
['entity', 'list', { filters }] 

Trade-offs and Design Decisions

Centralized vs. Distributed Query Hooks

ApproachBenefitsDrawbacksBest For
Centralized API hooksConsistency, single source of truthPotential for large files, tight couplingSmall to medium apps
Feature-based hooksBetter code organization, feature isolationPotential duplicationMedium to large apps

Our Decision: We use a feature-based organization for query hooks while sharing common functionality through a factory pattern, giving us both organization and reusability.

Implementation Considerations

Performance Implications

  • (Do ✅) Configure appropriate staleTime based on data volatility

    • More static data (e.g., product details): Longer staleTime (5-10 minutes)
    • More dynamic data (e.g., cart): Shorter staleTime (0-30 seconds)
  • (Do ✅) Use the select option to transform and minimize data

    • Transform and filter data on selection to minimize re-renders
    • Extract only the needed fields from larger response objects
  • (Don't ❌) Fetch the same data in multiple components

    • Create reusable query hooks to share data between components
    • Let TanStack Query handle the caching and deduplication

Security Considerations

  • (Do ✅) Validate all data from server responses

    • Use TypeScript interfaces to enforce data shape
    • Consider runtime validation with libraries like Zod or Yup
  • (Do ✅) Properly handle authentication errors

    • Set up global error handling for 401 responses
    • Redirect to login page or refresh tokens as needed
  • (Don't ❌) Store sensitive data in the query cache without encryption

    • Be cautious when persisting the query cache to storage
    • Consider what data should be excluded from persistence

Scalability Aspects

  • (Do ✅) Implement structured query keys for efficient invalidation

    • Create a query key factory for consistent key structure
    • Use parent keys to invalidate related queries
  • (Consider 🤔) Setting up query cache persistence

    • Improves offline experience and reduces initial loading
    • Balance with cache size management and potential staleness
  • (Be Aware ❗) Of memory usage with large datasets

    • Implement pagination or infinite queries for large lists
    • Consider data normalization for frequently used entities

Implementation Examples

Setting Up TanStack Query

// core/query/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
 
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutes
      cacheTime: 10 * 60 * 1000, // 10 minutes
      retry: 3,
      retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
      refetchOnWindowFocus: false,
      refetchOnMount: true,
    },
    mutations: {
      retry: 1,
    },
  },
});
 
// App.tsx
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '@/core/query';
 
export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* Your app components */}
    </QueryClientProvider>
  );
}

Query Key Factory

// core/query/queryKeys.ts
export const queryKeys = {
  all: ['queries'] as const,
  users: {
    all: ['users'] as const,
    lists: () => [...queryKeys.users.all, 'list'] as const,
    list: (filters: string) => [...queryKeys.users.lists(), { filters }] as const,
    details: () => [...queryKeys.users.all, 'detail'] as const,
    detail: (id: string) => [...queryKeys.users.details(), id] as const,
  },
  products: {
    all: ['products'] as const,
    lists: () => [...queryKeys.products.all, 'list'] as const,
    list: (filters: any) => [...queryKeys.products.lists(), { filters }] as const,
    details: () => [...queryKeys.products.all, 'detail'] as const,
    detail: (id: string) => [...queryKeys.products.details(), id] as const,
  },
  cart: {
    all: ['cart'] as const,
    current: () => [...queryKeys.cart.all, 'current'] as const,
  },
};

Query Patterns

// features/products/api/productHooks.ts
import { useQuery } from '@tanstack/react-query';
import { productService } from '@/core/api/services';
import { queryKeys } from '@/core/query';
 
export const useProductsQuery = (filters = {}) => {
  return useQuery({
    queryKey: queryKeys.products.list(filters),
    queryFn: () => productService.getProducts(filters),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });
};
 
export const useProductQuery = (productId: string) => {
  return useQuery({
    queryKey: queryKeys.products.detail(productId),
    queryFn: () => productService.getProduct(productId),
    enabled: !!productId,
  });
};

Mutation Patterns

// features/products/api/productMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { productService } from '@/core/api/services';
import { queryKeys } from '@/core/query';
 
export const useCreateProductMutation = () => {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: productService.createProduct,
    onSuccess: (newProduct) => {
      // Invalidate and refetch products list
      queryClient.invalidateQueries({ queryKey: queryKeys.products.lists() });
      
      // Add the new product to the cache
      queryClient.setQueryData(
        queryKeys.products.detail(newProduct.id),
        newProduct
      );
    },
  });
};

Advanced Patterns

// features/feed/api/feedHooks.ts
import { useInfiniteQuery } from '@tanstack/react-query';
 
export const useFeedQuery = () => {
  return useInfiniteQuery({
    queryKey: queryKeys.feed.all,
    queryFn: ({ pageParam = 1 }) => feedService.getFeedItems({ page: pageParam }),
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? lastPage.nextPage : undefined;
    },
    initialPageParam: 1,
  });
};
 
// Component usage
const FeedList = () => {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useFeedQuery();
 
  const items = data?.pages.flatMap(page => page.items) ?? [];
 
  return (
    <FlatList
      data={items}
      renderItem={({ item }) => <FeedItem item={item} />}
      onEndReached={() => {
        if (hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      }}
      ListFooterComponent={() =>
        isFetchingNextPage ? <ActivityIndicator /> : null
      }
    />
  );
};

Error Handling Strategies

// core/query/queryClient.ts
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,
      retry: 3,
      retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
      onError: (error) => {
        // Log the error
        console.error('Query error:', error);
        
        // You could trigger a global notification here
      },
    },
    mutations: {
      retry: 1,
      onError: (error, variables, context) => {
        // Log mutation errors
        console.error('Mutation error:', error, variables);
        
        // Show a toast message
        Toast.show({
          type: 'error',
          text1: 'Operation failed',
          text2: getErrorMessage(error),
        });
      },
    },
  },
});

Advanced Usage Patterns

// features/products/api/productMutations.ts
export const useUpdateProductMutation = () => {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: productService.updateProduct,
    onSuccess: (updatedProduct) => {
      // Update specific product in cache
      queryClient.setQueryData(
        queryKeys.products.detail(updatedProduct.id),
        updatedProduct
      );
 
      // Update product in any lists that might contain it
      queryClient.setQueriesData(
        { queryKey: queryKeys.products.lists() },
        (old: { products: Product[] }) => {
          if (!old) return old;
          return {
            ...old,
            products: old.products.map(product =>
              product.id === updatedProduct.id ? updatedProduct : product
            ),
          };
        }
      );
    },
  });
};

Testing Server State

// features/products/api/__tests__/productHooks.test.ts
import { renderHook, waitFor } from '@testing-library/react-native';
import { useProductsQuery } from '../productHooks';
import { productService } from '@/core/api/services';
import { createWrapper } from '@/test/utils';
 
jest.mock('@/core/api/services');
 
describe('useProductsQuery', () => {
  it('should fetch products successfully', async () => {
    const mockProducts = [{ id: '1', name: 'Test Product' }];
    (productService.getProducts as jest.Mock).mockResolvedValue(mockProducts);
 
    const { result } = renderHook(() => useProductsQuery(), {
      wrapper: createWrapper(),
    });
 
    expect(result.current.isLoading).toBe(true);
 
    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });
 
    expect(result.current.data).toEqual(mockProducts);
  });
 
  it('should handle errors', async () => {
    const error = new Error('Failed to fetch');
    (productService.getProducts as jest.Mock).mockRejectedValue(error);
 
    const { result } = renderHook(() => useProductsQuery(), {
      wrapper: createWrapper(),
    });
 
    await waitFor(() => {
      expect(result.current.isError).toBe(true);
    });
 
    expect(result.current.error).toEqual(error);
  });
});

Troubleshooting