UTA DevHub
Guides

Server State Management Guide

Practical patterns for managing server state using TanStack Query.

GUIDE-01: Server State Management Guide

Overview

This guide provides practical patterns for efficiently managing server state in React Native applications using TanStack Query. It explains how to implement data fetching, caching, and synchronization while maintaining a clear separation from client state.

When To Use

Use these patterns when working with any data that:

  • Originates from your backend API
  • Needs to be cached for performance
  • Requires loading/error states
  • Might become stale and need refreshing
  • Could be modified by multiple users

Server State Characteristics

Server state has fundamentally different characteristics from client state:

  • It requires managing loading, error, and success states
  • It's shared between the client and server
  • It may become outdated and need refreshing
  • It needs to reflect changes made by other users
  • It's affected by network conditions

Implementation Patterns

Basic Query Pattern

// src/features/products/api/productHooks.ts
import { useQuery } from '@tanstack/react-query';
import { productService } from '@/core/api/services/productService';
import { queryKeys } from '@/core/query/queryKeys';
 
export const useProductsQuery = (filters = {}) => {
  return useQuery({
    queryKey: queryKeys.products.list(filters),
    queryFn: () => productService.getProducts(filters),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });
};
 
// Usage in a component
const ProductList = () => {
  const [filters, setFilters] = useState({});
  const { data, isLoading, error } = useProductsQuery(filters);
  
  if (isLoading) return <LoadingIndicator />;
  if (error) return <ErrorDisplay error={error} />;
  
  return (
    <FlatList
      data={data}
      renderItem={({ item }) => <ProductItem product={item} />}
    />
  );
};

Mutation Pattern

// src/features/products/api/productHooks.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { productService } from '@/core/api/services/productService';
import { queryKeys } from '@/core/query/queryKeys';
 
export const useUpdateProductMutation = () => {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: ({ id, data }) => productService.updateProduct(id, data),
    onSuccess: (updatedProduct) => {
      // Update the cache with the new data
      queryClient.setQueryData(
        queryKeys.products.detail(updatedProduct.id),
        updatedProduct
      );
      
      // Invalidate any lists that might contain this product
      queryClient.invalidateQueries({ queryKey: queryKeys.products.list() });
    },
  });
};
 
// Usage in a component
const ProductForm = ({ product }) => {
  const { mutate, isLoading } = useUpdateProductMutation();
  
  const handleSubmit = (formData) => {
    mutate({ id: product.id, data: formData });
  };
  
  return (
    <Form onSubmit={handleSubmit} isSubmitting={isLoading} />
  );
};

Infinite Query Pattern

// src/features/products/api/productHooks.ts
import { useInfiniteQuery } from '@tanstack/react-query';
import { productService } from '@/core/api/services/productService';
import { queryKeys } from '@/core/query/queryKeys';
 
export const useProductsInfiniteQuery = (filters = {}) => {
  return useInfiniteQuery({
    queryKey: queryKeys.products.list(filters),
    queryFn: ({ pageParam = 1 }) => 
      productService.getProducts({ ...filters, page: pageParam }),
    getNextPageParam: (lastPage) => lastPage.nextPage || undefined,
  });
};
 
// Usage in a component
const ProductListInfinite = () => {
  const { 
    data, 
    fetchNextPage, 
    hasNextPage, 
    isFetchingNextPage,
    isLoading,
    error
  } = useProductsInfiniteQuery();
  
  if (isLoading) return <LoadingIndicator />;
  if (error) return <ErrorDisplay error={error} />;
  
  return (
    <FlatList
      data={data.pages.flatMap(page => page.products)}
      renderItem={({ item }) => <ProductItem product={item} />}
      onEndReached={() => hasNextPage && fetchNextPage()}
      ListFooterComponent={isFetchingNextPage ? <LoadingIndicator /> : null}
    />
  );
};

Dependent Queries

// src/features/orders/api/orderHooks.ts
import { useQuery } from '@tanstack/react-query';
import { orderService } from '@/core/api/services/orderService';
import { productService } from '@/core/api/services/productService';
import { queryKeys } from '@/core/query/queryKeys';
 
export const useOrderDetailsQuery = (orderId) => {
  // First query - get order details
  const orderQuery = useQuery({
    queryKey: queryKeys.orders.detail(orderId),
    queryFn: () => orderService.getOrder(orderId),
  });
  
  // Second query - depends on first query results
  const productIds = orderQuery.data?.items.map(item => item.productId) || [];
  
  const productsQuery = useQuery({
    queryKey: queryKeys.products.byIds(productIds),
    queryFn: () => productService.getProductsByIds(productIds),
    // Only run this query if we have product IDs
    enabled: productIds.length > 0,
  });
  
  return {
    order: orderQuery.data,
    products: productsQuery.data,
    isLoading: orderQuery.isLoading || productsQuery.isLoading,
    error: orderQuery.error || productsQuery.error,
  };
};

Query Invalidation

// src/features/cart/api/cartHooks.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { cartService } from '@/core/api/services/cartService';
import { queryKeys } from '@/core/query/queryKeys';
 
export const useAddToCartMutation = () => {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: ({ productId, quantity }) => 
      cartService.addItem(productId, quantity),
    onSuccess: () => {
      // Invalidate the cart queries
      queryClient.invalidateQueries({ queryKey: queryKeys.cart.all });
      
      // Invalidate product availability
      queryClient.invalidateQueries({ 
        queryKey: queryKeys.products.availability,
        exact: false 
      });
    },
  });
};

Optimistic Updates

// src/features/todos/api/todoHooks.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { todoService } from '@/core/api/services/todoService';
import { queryKeys } from '@/core/query/queryKeys';
 
export const useToggleTodoMutation = () => {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: (todoId) => todoService.toggleCompleted(todoId),
    
    // Update UI optimistically before the server responds
    onMutate: async (todoId) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: queryKeys.todos.all });
      
      // Get snapshot of current data
      const previousTodos = queryClient.getQueryData(queryKeys.todos.all);
      
      // Optimistically update the cache
      queryClient.setQueryData(
        queryKeys.todos.all,
        (old) => old.map(todo => 
          todo.id === todoId 
            ? { ...todo, completed: !todo.completed } 
            : todo
        )
      );
      
      // Return snapshot for rollback
      return { previousTodos };
    },
    
    // If error occurs, roll back to previous state
    onError: (err, todoId, context) => {
      queryClient.setQueryData(
        queryKeys.todos.all,
        context.previousTodos
      );
    },
    
    // Always refetch after error or success
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: queryKeys.todos.all });
    },
  });
};

Prefetching

// src/features/products/screens/ProductListScreen.tsx
import { useQueryClient } from '@tanstack/react-query';
import { productService } from '@/core/api/services/productService';
import { queryKeys } from '@/core/query/queryKeys';
 
const ProductListScreen = () => {
  const queryClient = useQueryClient();
  const navigation = useNavigation();
  
  const handleProductPress = (productId) => {
    // Prefetch product details before navigating
    queryClient.prefetchQuery({
      queryKey: queryKeys.products.detail(productId),
      queryFn: () => productService.getProduct(productId),
    });
    
    // Navigate to the product details screen
    navigation.navigate('ProductDetails', { productId });
  };
  
  // Rest of component...
};

Cross-Feature Data Sharing

Query Key Factory

// src/core/query/queryKeys.ts
export const queryKeys = {
  // User domain
  users: {
    all: ['users'] as const,
    current: () => [...queryKeys.users.all, 'current'] as const,
    detail: (id: string) => [...queryKeys.users.all, 'detail', id] as const,
    preferences: () => [...queryKeys.users.all, 'preferences'] as const,
  },
  
  // Product domain
  products: {
    all: ['products'] as const,
    list: (filters?: object) => [...queryKeys.products.all, 'list', filters] as const,
    detail: (id: string) => [...queryKeys.products.all, 'detail', id] as const,
    byIds: (ids: string[]) => [...queryKeys.products.all, 'byIds', ids] as const,
    availability: ['products', 'availability'] as const,
  },
  
  // Cart domain
  cart: {
    all: ['cart'] as const,
    items: () => [...queryKeys.cart.all, 'items'] as const,
    summary: () => [...queryKeys.cart.all, 'summary'] as const,
  },
  
  // Order domain
  orders: {
    all: ['orders'] as const,
    list: (filters?: object) => [...queryKeys.orders.all, 'list', filters] as const,
    detail: (id: string) => [...queryKeys.orders.all, 'detail', id] as const,
  },
};

Sharing Data Between Features

// src/features/profile/api/profileHooks.ts
import { useQuery } from '@tanstack/react-query';
import { userService } from '@/core/api/services/userService';
import { queryKeys } from '@/core/query/queryKeys';
 
export const useCurrentUserQuery = (options = {}) => 
  useQuery({
    queryKey: queryKeys.users.current(),
    queryFn: userService.getCurrentUser,
    ...options
  });
 
// In a different feature
// src/features/checkout/screens/CheckoutScreen.tsx
import { useCurrentUserQuery } from '@/features/profile/api/profileHooks';
 
const CheckoutScreen = () => {
  // Reuse user data query from profile feature
  const { data: user } = useCurrentUserQuery();
  
  // Feature-specific queries
  const { data: cartItems } = useCartItemsQuery();
  
  return (
    <View>
      <Text>Checkout for: {user?.name}</Text>
      {/* Rest of component */}
    </View>
  );
};

Initial Data from Existing Query

// src/features/products/api/productHooks.ts
export const useProductQuery = (productId: string) => {
  const queryClient = useQueryClient();
  
  return useQuery({
    queryKey: queryKeys.products.detail(productId),
    queryFn: () => productService.getProduct(productId),
    // Use data from the list query if available
    initialData: () => {
      const productLists = queryClient.getQueriesData({
        queryKey: queryKeys.products.list(),
        exact: false,
      });
      
      // Look through all product lists in the cache
      for (const [, data] of productLists) {
        const product = data?.find(p => p.id === productId);
        if (product) return product;
      }
      
      return undefined;
    },
    // Only consider initialData fresh if list was fetched recently
    initialDataUpdatedAt: () => {
      const listQueries = queryClient.getQueryCache().findAll({
        queryKey: queryKeys.products.list(),
        exact: false,
      });
      
      // Get the most recent updated timestamp
      const latestUpdatedAt = Math.max(
        ...listQueries.map(q => q.state.dataUpdatedAt)
      );
      
      return latestUpdatedAt > 0 ? latestUpdatedAt : undefined;
    },
  });
};

Common Challenges

1. Managing Loading States

// src/ui/components/QueryRenderer.tsx
import React from 'react';
import { UseQueryResult } from '@tanstack/react-query';
 
interface QueryRendererProps<TData> {
  query: UseQueryResult<TData>;
  LoadingComponent?: React.ReactNode;
  ErrorComponent?: React.ComponentType<{ error: Error }>;
  children: (data: TData) => React.ReactNode;
}
 
export function QueryRenderer<TData>({
  query,
  LoadingComponent = <DefaultLoader />,
  ErrorComponent = DefaultError,
  children,
}: QueryRendererProps<TData>) {
  const { data, isLoading, error } = query;
  
  if (isLoading) {
    return LoadingComponent;
  }
  
  if (error) {
    return <ErrorComponent error={error as Error} />;
  }
  
  return children(data as TData);
}
 
// Usage
const ProductScreen = () => {
  const productsQuery = useProductsQuery();
  
  return (
    <QueryRenderer
      query={productsQuery}
      LoadingComponent={<ProductSkeleton />}
      ErrorComponent={({ error }) => (
        <ProductLoadError error={error} onRetry={productsQuery.refetch} />
      )}
    >
      {(products) => (
        <ProductList products={products} />
      )}
    </QueryRenderer>
  );
};

2. Handling Stale Data

// src/features/dashboard/api/dashboardHooks.ts
export const useDashboardDataQuery = () => {
  return useQuery({
    queryKey: queryKeys.dashboard.summary(),
    queryFn: dashboardService.getSummary,
    // Configure stale time based on data freshness needs
    staleTime: 5 * 60 * 1000, // 5 minutes
    // Auto-refresh every 10 minutes
    refetchInterval: 10 * 60 * 1000,
    // Refresh when window/screen regains focus
    refetchOnWindowFocus: true,
    // Refresh when device reconnects
    refetchOnReconnect: true,
  });
};
 
// In the component
const DashboardScreen = () => {
  const { data, isLoading, isFetching, dataUpdatedAt, refetch } = useDashboardDataQuery();
  
  const lastUpdated = useMemo(() => {
    return new Date(dataUpdatedAt).toLocaleTimeString();
  }, [dataUpdatedAt]);
  
  return (
    <View>
      <View style={styles.header}>
        <Text>Dashboard</Text>
        <Text>Last updated: {lastUpdated}</Text>
        <Button 
          title="Refresh" 
          onPress={() => refetch()} 
          disabled={isFetching} 
        />
      </View>
      
      {isLoading ? (
        <LoadingIndicator />
      ) : (
        <DashboardContent data={data} />
      )}
      
      {/* Show small indicator when background refreshing */}
      {!isLoading && isFetching && (
        <ActivityIndicator size="small" style={styles.refreshIndicator} />
      )}
    </View>
  );
};

3. Network Connectivity Issues

// src/core/hooks/useOnlineStatus.ts
export const useOnlineStatus = () => {
  const [isOnline, setIsOnline] = useState(true);
  
  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener(state => {
      setIsOnline(!!state.isConnected && !!state.isInternetReachable);
    });
    
    return () => unsubscribe();
  }, []);
  
  return isOnline;
};
 
// Using in queries
export const useProductsQuery = (filters = {}) => {
  const isOnline = useOnlineStatus();
  
  return useQuery({
    queryKey: queryKeys.products.list(filters),
    queryFn: () => productService.getProducts(filters),
    // Don't retry when offline
    retry: isOnline ? 3 : false,
    // Show stale data longer when offline
    staleTime: isOnline ? 5 * 60 * 1000 : Infinity,
    // Don't refetch when offline
    refetchOnWindowFocus: isOnline,
    refetchOnReconnect: false,
    // Add error handler
    onError: (error) => {
      if (!isOnline) {
        // Show offline-specific error message
        Toast.show({
          type: 'info',
          text: 'You are currently offline. Some data may not be up to date.'
        });
      }
    },
  });
};

Performance Considerations

Selective Invalidation

// ❌ BAD: Invalidate too much
queryClient.invalidateQueries(); // Invalidates ALL queries
 
// ✅ GOOD: Invalidate only what's necessary
queryClient.invalidateQueries({ 
  queryKey: queryKeys.products.detail(productId) 
});

Pagination Optimization

// src/features/products/api/productHooks.ts
export const useProductsInfiniteQuery = (filters = {}) => {
  return useInfiniteQuery({
    queryKey: queryKeys.products.list(filters),
    queryFn: ({ pageParam = 1 }) => 
      productService.getProducts({ ...filters, page: pageParam }),
    getNextPageParam: (lastPage) => lastPage.nextPage || undefined,
    // Keep previous data while fetching next page
    keepPreviousData: true,
    // Improve performance with structural sharing
    structuralSharing: true,
  });
};

Strategic Prefetching

// src/features/products/screens/CategoryScreen.tsx
export const CategoryScreen = ({ categoryId }) => {
  const queryClient = useQueryClient();
  
  // Prefetch subcategories when component mounts
  useEffect(() => {
    queryClient.prefetchQuery({
      queryKey: queryKeys.categories.subcategories(categoryId),
      queryFn: () => categoryService.getSubcategories(categoryId),
    });
  }, [categoryId, queryClient]);
  
  // Prefetch popular products on hover/focus
  const prefetchPopularProducts = useCallback(() => {
    queryClient.prefetchQuery({
      queryKey: queryKeys.products.popular(categoryId),
      queryFn: () => productService.getPopularProducts(categoryId),
    });
  }, [categoryId, queryClient]);
  
  return (
    <View>
      <TouchableOpacity 
        onPressIn={prefetchPopularProducts}
        onPress={() => navigation.navigate('PopularProducts', { categoryId })}
      >
        <Text>View Popular Products</Text>
      </TouchableOpacity>
      
      {/* Rest of component */}
    </View>
  );
};

Cache Time Configuration

// src/core/query/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
 
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // How long data stays fresh (not refetched)
      staleTime: 5 * 60 * 1000, // 5 minutes
      // How long unused data stays in cache before garbage collection
      cacheTime: 30 * 60 * 1000, // 30 minutes
      retry: 1,
      refetchOnWindowFocus: false,
      refetchOnMount: true,
    },
    mutations: {
      retry: 1,
    },
  },
});

Examples

Authentication Flow

// src/features/auth/api/authHooks.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { userService } from '@/core/api/services/userService';
import { queryKeys } from '@/core/query/queryKeys';
import { useAuthStore } from '../state/authStore';
 
export const useLoginMutation = () => {
  const setAuthToken = useAuthStore((state) => state.setAuthToken);
  const setUser = useAuthStore((state) => state.setUser);
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: userService.login,
    onSuccess: (data) => {
      // 1. Update client state with auth token (Zustand)
      setAuthToken(data.token);
      setUser(data.user);
      
      // 2. Store user data in query cache (server state)
      queryClient.setQueryData(queryKeys.users.current(), data.user);
      
      // 3. Setup auth header for future API calls
      apiClient.defaults.headers.common['Authorization'] = `Bearer ${data.token}`;
    },
  });
};
 
export const useLogoutMutation = () => {
  const queryClient = useQueryClient();
  const logout = useAuthStore((state) => state.logout);
  
  return useMutation({
    mutationFn: userService.logout,
    onSuccess: () => {
      // 1. Clear client state (Zustand)
      logout();
      
      // 2. Reset the query cache to clear all server state
      queryClient.clear();
      
      // 3. Remove auth header from API client
      delete apiClient.defaults.headers.common['Authorization'];
    },
  });
};

Real-Time Data Updates

// src/features/notifications/api/notificationHooks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notificationService } from '@/core/api/services/notificationService';
import { queryKeys } from '@/core/query/queryKeys';
import { useEffect } from 'react';
import { socket } from '@/core/api/socket';
import { useNotificationStore } from '../state/useNotificationStore';
 
export const useNotificationsQuery = () => {
  const queryClient = useQueryClient();
  const socket = useWebsocket();
  
  // Regular query for initial data
  const query = useQuery({
    queryKey: queryKeys.notifications.list(),
    queryFn: notificationService.getNotifications,
  });
  
  // Listen for real-time updates
  useEffect(() => {
    if (!socket) return;
    
    const showNotification = useNotificationStore((state) => state.showNotificationBanner);
    
    const handleNewNotification = (notification) => {
      // Update cache with new data
      queryClient.setQueryData(
        queryKeys.notifications.list(),
        (oldData = []) => [notification, ...oldData]
      );
      
      // Show notification UI
      showNotification(notification);
    };
    
    socket.on('notification', handleNewNotification);
    
    return () => {
      socket.off('notification', handleNewNotification);
    };
  }, [socket, queryClient, showNotification]);
  
  return query;
};

Complex Forms with Server Validation

// src/features/profile/components/ProfileForm/ProfileForm.tsx
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useUpdateProfileMutation } from '../../api/profileHooks';
import { useNavigation } from '@react-navigation/native';
 
export const ProfileForm = ({ initialData }) => {
  // Local form state
  const { control, handleSubmit, setError } = useForm({
    defaultValues: initialData
  });
  
  // Server mutation state
  const { mutate, isLoading } = useUpdateProfileMutation({
    onError: (error) => {
      // Handle server validation errors
      if (error.response?.data?.fieldErrors) {
        // Map server errors back to form fields
        Object.entries(error.response.data.fieldErrors).forEach(
          ([field, message]) => setError(field, { message })
        );
      }
    },
    onSuccess: () => {
      // Navigation on success
      navigation.navigate('ProfileSuccess');
    }
  });
  
  const navigation = useNavigation();
  
  const onSubmit = (data) => {
    mutate(data);
  };
  
  return (
    <Form 
      control={control} 
      onSubmit={handleSubmit(onSubmit)} 
      isLoading={isLoading}
    />
  );
};
  • DOC-01: Core Architecture Reference
  • DOC-03: API & State Management Reference
  • GUIDE-02: State Management Implementation Guide
  • GUIDE-06: Offline Support Implementation Guide