UTA DevHub

State Integration Patterns

Common patterns for integrating server and client state in React Native applications

State Integration Patterns

Overview

This document outlines architectural patterns for effectively integrating different types of state management in React Native applications. It focuses on the coordination between server state (TanStack Query), client state (Zustand), and component state (React), providing solutions for common state integration challenges while maintaining a clean separation of concerns.

Purpose & Scope

  • Target Audience: React Native developers implementing features that require coordination between multiple state management approaches
  • Problems Addressed: Complex state interactions, synchronization challenges, and maintaining data consistency
  • Scope: Covers integration patterns specifically for authentication flows, shopping carts, forms with drafts, real-time updates, and filter/search functionality

Authentication Flow Pattern

A complete authentication flow demonstrates how to coordinate between different state types:

// features/auth/state/authStore.ts (Client State)
interface AuthState {
  token: string | null;
  refreshToken: string | null;
  setTokens: (token: string, refreshToken: string) => void;
  clearTokens: () => void;
}
 
export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      token: null,
      refreshToken: null,
      setTokens: (token, refreshToken) => set({ token, refreshToken }),
      clearTokens: () => set({ token: null, refreshToken: null }),
    }),
    {
      name: 'auth-storage',
      storage: AsyncStorage,
    }
  )
);
 
// features/auth/api/authHooks.ts (Server State)
export const useCurrentUser = () => {
  const token = useAuthStore((state) => state.token);
  
  return useQuery({
    queryKey: ['user', 'me'],
    queryFn: userService.getCurrentUser,
    enabled: !!token,
  });
};
 
export const useLogin = () => {
  const queryClient = useQueryClient();
  const { setTokens } = useAuthStore();
  
  return useMutation({
    mutationFn: authService.login,
    onSuccess: (data) => {
      // Update client state with tokens
      setTokens(data.token, data.refreshToken);
      
      // Update server state cache
      queryClient.setQueryData(['user', 'me'], data.user);
      
      // Invalidate protected queries
      queryClient.invalidateQueries({ 
        predicate: (query) => query.queryKey[0] === 'protected' 
      });
    },
  });
};
 
export const useLogout = () => {
  const queryClient = useQueryClient();
  const { clearTokens } = useAuthStore();
  
  return useMutation({
    mutationFn: authService.logout,
    onSuccess: () => {
      // Clear client state
      clearTokens();
      
      // Clear all cached data
      queryClient.clear();
    },
  });
};
 
// features/auth/components/LoginScreen.tsx (Component State)
const LoginScreen = () => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [rememberMe, setRememberMe] = useState(false);
  
  const loginMutation = useLogin();
  const navigation = useNavigation();
  
  const handleLogin = async () => {
    try {
      await loginMutation.mutateAsync({ 
        email, 
        password, 
        rememberMe 
      });
      navigation.navigate('Home');
    } catch (error) {
      // Handle error
    }
  };
  
  return (
    // UI implementation
  );
};

Shopping Cart with Sync Pattern

Managing a shopping cart that syncs with the server:

// features/cart/state/cartStore.ts (Client State)
interface CartState {
  localItems: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (itemId: string) => void;
  updateQuantity: (itemId: string, quantity: number) => void;
  clearLocalCart: () => void;
}
 
export const useCartStore = create<CartState>((set) => ({
  localItems: [],
  
  addItem: (item) => set((state) => {
    const existing = state.localItems.find(i => i.id === item.id);
    if (existing) {
      return {
        localItems: state.localItems.map(i =>
          i.id === item.id 
            ? { ...i, quantity: i.quantity + item.quantity }
            : i
        ),
      };
    }
    return { localItems: [...state.localItems, item] };
  }),
  
  removeItem: (itemId) => set((state) => ({
    localItems: state.localItems.filter(item => item.id !== itemId),
  })),
  
  updateQuantity: (itemId, quantity) => set((state) => ({
    localItems: state.localItems.map(item =>
      item.id === itemId ? { ...item, quantity } : item
    ),
  })),
  
  clearLocalCart: () => set({ localItems: [] }),
}));
 
// features/cart/api/cartHooks.ts (Server State)
export const useCart = () => {
  const { data: user } = useCurrentUser();
  
  return useQuery({
    queryKey: ['cart', user?.id],
    queryFn: () => cartService.getCart(),
    enabled: !!user,
  });
};
 
export const useSyncCart = () => {
  const queryClient = useQueryClient();
  const localItems = useCartStore((state) => state.localItems);
  const { clearLocalCart } = useCartStore();
  const { data: user } = useCurrentUser();
  
  const syncMutation = useMutation({
    mutationFn: (items: CartItem[]) => cartService.syncCart(items),
    onSuccess: (data) => {
      // Update server state
      queryClient.setQueryData(['cart', user?.id], data);
      
      // Clear local cart after successful sync
      clearLocalCart();
    },
  });
  
  // Sync local cart with server when user logs in
  useEffect(() => {
    if (user && localItems.length > 0) {
      syncMutation.mutate(localItems);
    }
  }, [user]);
  
  return syncMutation;
};
 
// features/cart/components/CartButton.tsx
const CartButton = () => {
  const { data: serverCart } = useCart();
  const localItems = useCartStore((state) => state.localItems);
  const { data: user } = useCurrentUser();
  
  // Show server cart count for logged-in users, local cart for guests
  const itemCount = user 
    ? serverCart?.items.length || 0 
    : localItems.length;
  
  return (
    <Button>
      <Text>Cart ({itemCount})</Text>
    </Button>
  );
};

Form with Draft Saving Pattern

Complex form with local state and server persistence:

// features/posts/state/postDraftStore.ts (Client State)
interface DraftState {
  drafts: Record<string, PostDraft>;
  saveDraft: (id: string, draft: PostDraft) => void;
  getDraft: (id: string) => PostDraft | undefined;
  deleteDraft: (id: string) => void;
}
 
export const useDraftStore = create<DraftState>()(
  persist(
    (set, get) => ({
      drafts: {},
      
      saveDraft: (id, draft) => set((state) => ({
        drafts: { ...state.drafts, [id]: draft },
      })),
      
      getDraft: (id) => get().drafts[id],
      
      deleteDraft: (id) => set((state) => {
        const { [id]: _, ...rest } = state.drafts;
        return { drafts: rest };
      }),
    }),
    {
      name: 'post-drafts',
      storage: AsyncStorage,
    }
  )
);
 
// features/posts/components/PostEditor.tsx (Component State + Integration)
const PostEditor = ({ postId }: { postId?: string }) => {
  const queryClient = useQueryClient();
  const { saveDraft, getDraft, deleteDraft } = useDraftStore();
  
  // Server state for existing post
  const { data: existingPost } = useQuery({
    queryKey: ['post', postId],
    queryFn: () => postService.getPost(postId!),
    enabled: !!postId,
  });
  
  // Component state for form
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');
  const [isDirty, setIsDirty] = useState(false);
  
  // Initialize from server data or draft
  useEffect(() => {
    if (existingPost) {
      setTitle(existingPost.title);
      setContent(existingPost.content);
    } else {
      const draft = getDraft(postId || 'new');
      if (draft) {
        setTitle(draft.title);
        setContent(draft.content);
      }
    }
  }, [existingPost, postId]);
  
  // Auto-save draft
  useEffect(() => {
    if (isDirty) {
      const timeoutId = setTimeout(() => {
        saveDraft(postId || 'new', { title, content });
        setIsDirty(false);
      }, 1000);
      
      return () => clearTimeout(timeoutId);
    }
  }, [title, content, isDirty]);
  
  // Save to server
  const saveMutation = useMutation({
    mutationFn: (data: PostData) => 
      postId ? postService.updatePost(postId, data) : postService.createPost(data),
    onSuccess: (savedPost) => {
      // Clear draft after successful save
      deleteDraft(postId || 'new');
      
      // Update cache
      queryClient.setQueryData(['post', savedPost.id], savedPost);
      queryClient.invalidateQueries({ queryKey: ['posts', 'list'] });
      
      // Navigate to saved post
      navigation.navigate('PostDetail', { postId: savedPost.id });
    },
  });
  
  const handleSave = () => {
    saveMutation.mutate({ title, content });
  };
  
  return (
    <View>
      <TextInput
        value={title}
        onChangeText={(text) => {
          setTitle(text);
          setIsDirty(true);
        }}
        placeholder="Post title"
      />
      <TextInput
        value={content}
        onChangeText={(text) => {
          setContent(text);
          setIsDirty(true);
        }}
        placeholder="Post content"
        multiline
      />
      <Button
        title="Save"
        onPress={handleSave}
        disabled={saveMutation.isLoading}
      />
    </View>
  );
};

Real-time Updates with Polling Pattern

Combining server polling with optimistic client updates:

// features/chat/api/chatHooks.ts (Server State)
export const useMessages = (chatId: string) => {
  return useQuery({
    queryKey: ['messages', chatId],
    queryFn: () => chatService.getMessages(chatId),
    refetchInterval: 5000, // Poll every 5 seconds
  });
};
 
// features/chat/state/chatStore.ts (Client State)
interface ChatState {
  pendingMessages: Map<string, Message>;
  addPendingMessage: (tempId: string, message: Message) => void;
  removePendingMessage: (tempId: string) => void;
}
 
export const useChatStore = create<ChatState>((set) => ({
  pendingMessages: new Map(),
  
  addPendingMessage: (tempId, message) => set((state) => {
    const newMap = new Map(state.pendingMessages);
    newMap.set(tempId, message);
    return { pendingMessages: newMap };
  }),
  
  removePendingMessage: (tempId) => set((state) => {
    const newMap = new Map(state.pendingMessages);
    newMap.delete(tempId);
    return { pendingMessages: newMap };
  }),
}));
 
// features/chat/components/ChatScreen.tsx
const ChatScreen = ({ chatId }: { chatId: string }) => {
  const queryClient = useQueryClient();
  const { data: messages = [] } = useMessages(chatId);
  const { pendingMessages, addPendingMessage, removePendingMessage } = useChatStore();
  
  const sendMessageMutation = useMutation({
    mutationFn: chatService.sendMessage,
    onMutate: async (newMessage) => {
      const tempId = Date.now().toString();
      const optimisticMessage = {
        ...newMessage,
        id: tempId,
        status: 'pending',
        timestamp: new Date().toISOString(),
      };
      
      // Add to pending messages
      addPendingMessage(tempId, optimisticMessage);
      
      return { tempId };
    },
    onSuccess: (data, variables, context) => {
      // Remove from pending
      removePendingMessage(context.tempId);
      
      // Update cache
      queryClient.setQueryData(['messages', chatId], (old: Message[] = []) => {
        return [...old, data];
      });
    },
    onError: (error, variables, context) => {
      // Mark message as failed
      const failed = pendingMessages.get(context.tempId);
      if (failed) {
        addPendingMessage(context.tempId, { ...failed, status: 'failed' });
      }
    },
  });
  
  // Combine server messages with pending client messages
  const allMessages = [
    ...messages,
    ...Array.from(pendingMessages.values()),
  ].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
  
  return (
    <FlatList
      data={allMessages}
      renderItem={({ item }) => (
        <MessageItem 
          message={item} 
          isPending={pendingMessages.has(item.id)}
        />
      )}
    />
  );
};

Filter and Search State Pattern

Managing complex filter state with server queries:

// features/products/state/productFiltersStore.ts (Client State)
interface FilterState {
  category: string | null;
  priceRange: [number, number];
  sortBy: 'price' | 'rating' | 'newest';
  searchQuery: string;
  
  setCategory: (category: string | null) => void;
  setPriceRange: (range: [number, number]) => void;
  setSortBy: (sort: 'price' | 'rating' | 'newest') => void;
  setSearchQuery: (query: string) => void;
  resetFilters: () => void;
}
 
export const useProductFiltersStore = create<FilterState>((set) => ({
  category: null,
  priceRange: [0, 1000],
  sortBy: 'newest',
  searchQuery: '',
  
  setCategory: (category) => set({ category }),
  setPriceRange: (priceRange) => set({ priceRange }),
  setSortBy: (sortBy) => set({ sortBy }),
  setSearchQuery: (searchQuery) => set({ searchQuery }),
  resetFilters: () => set({
    category: null,
    priceRange: [0, 1000],
    sortBy: 'newest',
    searchQuery: '',
  }),
}));
 
// features/products/api/productHooks.ts (Server State)
export const useFilteredProducts = () => {
  const filters = useProductFiltersStore();
  const debouncedSearch = useDebounce(filters.searchQuery, 500);
  
  return useQuery({
    queryKey: ['products', 'filtered', {
      category: filters.category,
      priceRange: filters.priceRange,
      sortBy: filters.sortBy,
      search: debouncedSearch,
    }],
    queryFn: () => productService.getProducts({
      category: filters.category,
      minPrice: filters.priceRange[0],
      maxPrice: filters.priceRange[1],
      sortBy: filters.sortBy,
      search: debouncedSearch,
    }),
    keepPreviousData: true, // Show previous results while fetching
  });
};
 
// features/products/components/ProductListScreen.tsx
const ProductListScreen = () => {
  const { data, isLoading, isFetching } = useFilteredProducts();
  const { searchQuery, setSearchQuery } = useProductFiltersStore();
  
  return (
    <View>
      <SearchBar
        value={searchQuery}
        onChangeText={setSearchQuery}
        placeholder="Search products..."
      />
      <FilterBar />
      {isLoading ? (
        <LoadingSpinner />
      ) : (
        <FlatList
          data={data?.products}
          renderItem={({ item }) => <ProductCard product={item} />}
          ListFooterComponent={() => isFetching ? <ActivityIndicator /> : null}
        />
      )}
    </View>
  );
};

Core Components/Architecture

The integration patterns involve these key components working together:

Component Types

ComponentResponsibilityCommon Patterns
Component StateUI interactions, form inputs, temporary dataForm handling, UI element state, local interactions
Zustand StoreApplication-wide client state, persistent preferencesAuth state, preferences, cart data, offline support
TanStack QueryServer data fetching, caching, synchronizationAPI responses, optimistic updates, background polling
Integration LayerCoordinating data flow between state typesEvent handlers, useEffect hooks, side effects

Communication Patterns

  • Component → Zustand: Direct state updates via store actions
  • Component → TanStack Query: Query invalidation, cache manipulation
  • Zustand → TanStack Query: Query enablement, dependent queries
  • TanStack Query → API: Data fetching, mutations with optimistic updates

Design Principles

Core Architectural Principles

  • (Do ✅) Establish clear ownership for each piece of state:

    • Server data → TanStack Query
    • UI state → Zustand or Component State
    • Form data → Component State until submission
  • (Don't ❌) Store server data in client state unnecessarily:

    • Avoid duplication between TanStack Query cache and Zustand
    • Reference server data directly in components when possible
  • (Do ✅) Implement optimistic updates for better UX:

    • Update UI immediately for responsive feedback
    • Sync with server in background
    • Handle failures gracefully with rollback mechanisms

Trade-offs and Decisions

ApproachBenefitsDrawbacksBest For
Minimal state integrationSimpler mental model, clearer boundariesMay lead to prop drilling, more boilerplateSimple screens, isolated features
Tightly coupled stateFewer lines of code, direct coordinationHarder to debug, potential circular dependenciesComplex interactions, real-time features
Event-based coordinationLoose coupling, easier to testMore complex initial setupLarge teams, complex state flows

Implementation Considerations

Performance Implications

  • (Do ✅) Use selective Zustand subscriptions to prevent unnecessary renders
  • (Consider 🤔) Configuring TanStack Query with appropriate staleTime/gcTime settings based on data volatility
  • (Do ✅) Apply React memoization techniques (memo, useMemo, useCallback) at integration boundaries
  • (Be Aware ❗) Integration points between state systems are common sources of render loops

Security Considerations

  • (Do ✅) Never store sensitive information (tokens, PII) in unencrypted client state
  • (Do ✅) Apply appropriate validation at both client and server boundaries
  • (Don't ❌) Trust client state for security decisions - always validate permissions on the server

Scalability Aspects

  • (Consider 🤔) Breaking large state stores into smaller, feature-focused ones
  • (Do ✅) Design query key factories for consistent cache manipulation
  • (Be Aware ❗) Complex state interactions may require dedicated middleware or effect systems

Common Integration Patterns

The following sections detail specific integration patterns for common scenarios. Each pattern demonstrates how to effectively coordinate between different state management systems.