State Management Rules
RNCopilot uses a two-layer state architecture. Server/async state is managed by React Query. Client/UI state is managed by Zustand. These two layers have clear responsibilities and never overlap.
Two-Layer Architecture
| Layer | Library | Responsibility |
|---|---|---|
| Server state | @tanstack/react-query | Data from APIs, loading states, caching, background refetching |
| Client state | Zustand | User preferences, auth session, UI toggles, local-only state |
Do not use React Context for state management. There is no AuthContext or AuthProvider in this project. Auth is a Zustand store.
Zustand Store Pattern
Every Zustand store follows the same structure: a state interface, initial state, and actions.
// src/features/myFeature/stores/myFeatureStore.ts
import { create } from 'zustand';
import type { MyItem } from '../types';
interface MyFeatureState {
// State
items: MyItem[];
selectedId: string | null;
// Actions
setItems: (items: MyItem[]) => void;
selectItem: (id: string | null) => void;
addItem: (item: MyItem) => void;
removeItem: (id: string) => void;
reset: () => void;
}
const initialState = {
items: [],
selectedId: 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),
}));Always Use Selectors
Never subscribe to the entire Zustand store. Always use a selector to pick only what the component needs. Subscribing to the whole store causes unnecessary re-renders.
// CORRECT -- subscribes only to what is needed
const items = useMyFeatureStore((s) => s.items);
const addItem = useMyFeatureStore((s) => s.addItem);
// WRONG -- subscribes to all state, re-renders on any change
const store = useMyFeatureStore();Auth Store
Auth is managed exclusively by useAuthStore in src/providers/auth/authStore.ts. This is the only way to read or modify auth state.
Reading Auth State in Components
import { useAuthStore } from '@/providers/auth/authStore';
const user = useAuthStore((s) => s.user);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isLoading = useAuthStore((s) => s.isLoading);Accessing Auth Outside React
In non-React code (e.g., Axios interceptors), use getState():
const session = useAuthStore.getState().session;Never create a React Context for auth. Never manage session state locally in a component. All auth flows go through useAuthStore.
React Query Conventions
Query Key Factory Pattern
Use factory functions for query keys, not 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 Hooks
Place all query and mutation hooks in src/features/<feature>/hooks/:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/services/api';
import type { Product, CreateProductData } from '../types';
export function useProducts(filters?: object) {
return useQuery({
queryKey: PRODUCT_KEYS.list(filters),
queryFn: () =>
api.get<Product[]>('/products', { params: filters })
.then((r) => r.data),
});
}
export function useProduct(id: string) {
return useQuery({
queryKey: PRODUCT_KEYS.detail(id),
queryFn: () =>
api.get<Product>(`/products/${id}`).then((r) => r.data),
enabled: !!id,
});
}
export function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateProductData) =>
api.post<Product>('/products', data).then((r) => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: PRODUCT_KEYS.all });
},
});
}Rules Summary
- One query key factory per feature, exported as a
constobject. - Use
enabledto conditionally run queries (e.g.,enabled: !!id). - Invalidate via the factory:
queryClient.invalidateQueries({ queryKey: PRODUCT_KEYS.all }). - Place API calls in
queryFndirectly or extract them into service files undersrc/features/<feature>/services/.