State Management
RNCopilot uses a two-layer architecture that cleanly separates server state from client state:
| Layer | Technology | Responsibility |
|---|---|---|
| Server state | @tanstack/react-query | Data from APIs, loading states, caching, refetching, mutations |
| Client state | Zustand | User 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 Type | Solution | Example |
|---|---|---|
| API response data | React Query | Product list, user profile from server |
| Loading/error for API calls | React Query | isLoading, error from useQuery |
| Auth session | Zustand (useAuthStore) | User object, access token |
| Theme preference | MMKV (via useStorage) | Light/dark mode choice |
| UI toggle | useState | Modal visibility, accordion open state |
| Form state | react-hook-form | Input values, validation errors |
| Cross-screen UI state | Zustand | Shopping cart, selected filters |
| Feature flags | Zustand or React Query | Depends on source (local vs. remote) |