Skip to Content
Core ConceptsState Management

State Management

RNCopilot uses a two-layer architecture that cleanly separates server state from client state:

LayerTechnologyResponsibility
Server state@tanstack/react-queryData from APIs, loading states, caching, refetching, mutations
Client stateZustandUser preferences, auth session, UI toggles, local-only data

Why Two Layers?

Server state and client state have fundamentally different characteristics:

  • Server state is asynchronous, cached, shared across components, and may become stale. React Query handles all of this automatically.
  • Client state is synchronous, local to the device, and always current. Zustand provides a minimal, selector-based API for this.

Mixing these concerns (e.g., storing API responses in Zustand) leads to manual cache invalidation, stale data bugs, and unnecessary complexity.

Zustand Stores

Store Pattern

// src/features/cart/stores/cartStore.ts import { create } from 'zustand'; import type { CartItem, Product } from '../types'; interface CartState { // State items: CartItem[]; // Actions addItem: (product: Product, quantity?: number) => void; removeItem: (productId: string) => void; updateQuantity: (productId: string, quantity: number) => void; clearCart: () => void; // Derived totalItems: () => number; totalPrice: () => number; } export const useCartStore = create<CartState>((set, get) => ({ items: [], addItem: (product, quantity = 1) => set((state) => { const existing = state.items.find((i) => i.productId === product.id); if (existing) { return { items: state.items.map((i) => i.productId === product.id ? { ...i, quantity: i.quantity + quantity } : i ), }; } return { items: [...state.items, { productId: product.id, product, quantity }], }; }), removeItem: (productId) => set((state) => ({ items: state.items.filter((i) => i.productId !== productId), })), updateQuantity: (productId, quantity) => set((state) => ({ items: quantity <= 0 ? state.items.filter((i) => i.productId !== productId) : state.items.map((i) => i.productId === productId ? { ...i, quantity } : i ), })), clearCart: () => set({ items: [] }), totalItems: () => get().items.reduce((sum, i) => sum + i.quantity, 0), totalPrice: () => get().items.reduce((sum, i) => sum + i.product.price * i.quantity, 0), }));

Selector Pattern

Always use selectors. Never destructure the entire store. Subscribing to the whole store causes unnecessary re-renders whenever any slice of state changes.

// CORRECT -- only re-renders when `items` changes const items = useCartStore((s) => s.items); const addItem = useCartStore((s) => s.addItem); const total = useCartStore((s) => s.totalPrice()); // WRONG -- re-renders on every state change const { items, addItem, removeItem } = useCartStore();

Store Template

When creating a new store, follow this structure:

// src/features/myFeature/stores/myFeatureStore.ts import { create } from 'zustand'; import type { MyItem } from '../types'; interface MyFeatureState { items: MyItem[]; selectedId: string | null; isLoading: boolean; error: string | null; setItems: (items: MyItem[]) => void; selectItem: (id: string | null) => void; addItem: (item: MyItem) => void; removeItem: (id: string) => void; reset: () => void; } const initialState = { items: [], selectedId: null, isLoading: false, error: null, }; export const useMyFeatureStore = create<MyFeatureState>((set) => ({ ...initialState, setItems: (items) => set({ items }), selectItem: (id) => set({ selectedId: id }), addItem: (item) => set((state) => ({ items: [...state.items, item] })), removeItem: (id) => set((state) => ({ items: state.items.filter((i) => i.id !== id), })), reset: () => set(initialState), }));

Auth Store

The auth store is the most important Zustand store in the template. It manages the user session and is used by both React components and non-React code (like Axios interceptors).

// src/providers/auth/authStore.ts export const useAuthStore = create<AuthState>((set) => ({ user: null, session: null, isLoading: true, isAuthenticated: false, setUser: (user) => set({ user, isAuthenticated: !!user }), setSession: (session) => set({ session }), setLoading: (isLoading) => set({ isLoading }), clearSession: () => set({ user: null, session: null, isAuthenticated: false }), initialize: async () => { // Add your auth initialization logic here set({ isLoading: false }); }, cleanup: () => { // Clean up auth subscriptions here }, }));

Reading Auth State

// In React components -- use selectors const user = useAuthStore((s) => s.user); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isLoading = useAuthStore((s) => s.isLoading); // Outside React (Axios interceptors, utility functions) const session = useAuthStore.getState().session;

Only use useAuthStore.getState() in non-React code. Inside components, always use the hook with a selector to get proper reactivity.

React Query

Query Key Factory

Use factory functions for query keys instead of inline arrays. This enables precise cache invalidation:

// src/features/products/hooks/useProducts.ts export const PRODUCT_KEYS = { all: ['products'] as const, list: (filters?: object) => [...PRODUCT_KEYS.all, 'list', filters] as const, detail: (id: string) => [...PRODUCT_KEYS.all, 'detail', id] as const, };

Query Hook

import { useQuery } from '@tanstack/react-query'; import { fetchProducts } from '../services/productService'; export function useProducts(filters?: object) { return useQuery({ queryKey: PRODUCT_KEYS.list(filters), queryFn: () => fetchProducts(filters), }); }

Mutation Hook

import { useMutation, useQueryClient } from '@tanstack/react-query'; import { createProduct } from '../services/productService'; import type { CreateProductData } from '../types'; export function useCreateProduct() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (data: CreateProductData) => createProduct(data), onSuccess: () => { // Invalidate all product queries to refetch fresh data queryClient.invalidateQueries({ queryKey: PRODUCT_KEYS.all }); }, }); }

Cache Persistence

React Query’s cache is persisted to MMKV via a custom persister in src/providers/query/mmkvPersister.ts. This means cached data survives app restarts, providing an instant UI while fresh data loads in the background.

Where Each Type of State Belongs

State TypeSolutionExample
API response dataReact QueryProduct list, user profile from server
Loading/error for API callsReact QueryisLoading, error from useQuery
Auth sessionZustand (useAuthStore)User object, access token
Theme preferenceMMKV (via useStorage)Light/dark mode choice
UI toggleuseStateModal visibility, accordion open state
Form statereact-hook-formInput values, validation errors
Cross-screen UI stateZustandShopping cart, selected filters
Feature flagsZustand or React QueryDepends on source (local vs. remote)
Last updated on