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}
/>
);
};Related Documents
- DOC-01: Core Architecture Reference
- DOC-03: API & State Management Reference
- GUIDE-02: State Management Implementation Guide
- GUIDE-06: Offline Support Implementation Guide