State Management
| Field | Value |
|---|---|
| Type | Agent Reference |
| Source | ~/.copilot/agents/_refs/expert-react-frontend-engineer/state-management.md |
| Description | Not specified |
Source Content
State Management
The State Decision Framework
When adding state to the application, follow this decision tree:
Is this data from a server/API? YES → TanStack Query (useQuery / useMutation) NO ↓
Should this state be in the URL (bookmarkable, shareable, navigable)? YES → URL search params or route params NO ↓
Is this state shared across multiple components? YES → Zustand store NO ↓
Is this state local to one component? YES → useState / useReducer NO ↓
Rethink the architecture. Something is off.Never combine these layers. Server state in Zustand is a bug. URL state in useState is a bug. Follow the decision tree.
URL State — TanStack Router
Use TanStack Router search params for all bookmarkable/navigable UI state. Define the schema in the route:
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'import { userFiltersSchema } from './user.schema'
// Route definition validates search params automaticallyexport const Route = createFileRoute('/users')({ validateSearch: (search) => userFiltersSchema.parse(search),})
// In the component — fully type-safe, no manual parsingfunction UsersPage() { const search = useSearch({ from: '/users' }) const navigate = useNavigate({ from: '/users' })
const handleTabChange = (tab: string) => { navigate({ search: (prev) => ({ ...prev, tab, page: 1 }) }) }}Never use useState for state that belongs in the URL. TanStack Router + Zod makes URL state type-safe and validated without manual work.
TanStack React Query — Server State Management
Use for: All server data fetching, caching, mutations, optimistic updates, pagination, and infinite scroll.
Never use for: Client-only state (use Zustand) or URL state (use router).
Key conventions:
// Query key factory — always define at the module levelexport const userKeys = { all: ["users"] as const, lists: () => [...userKeys.all, "list"] as const, list: (filters: UserFilters) => [...userKeys.lists(), filters] as const, details: () => [...userKeys.all, "detail"] as const, detail: (id: string) => [...userKeys.details(), id] as const,};
// Custom query hook — encapsulates the query configurationexport function useUsers(filters: UserFilters) { return useQuery({ queryKey: userKeys.list(filters), queryFn: () => api.users.list(filters), staleTime: 5 * 60 * 1000, // 5 minutes });}
// Custom mutation hook — encapsulates mutation + cache invalidationexport function useDeleteUser() { const queryClient = useQueryClient();
return useMutation({ mutationFn: (id: string) => api.users.delete(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: userKeys.lists() }); }, // Optimistic update pattern when needed onMutate: async (id) => { await queryClient.cancelQueries({ queryKey: userKeys.lists() }); const previous = queryClient.getQueryData(userKeys.lists()); queryClient.setQueryData(userKeys.lists(), (old: User[]) => old.filter((u) => u.id !== id)); return { previous }; }, onError: (_err, _id, context) => { queryClient.setQueryData(userKeys.lists(), context?.previous); }, });}Rules:
- Always use query key factories (shown above)
- Always create custom hooks for queries — never call
useQuerydirectly in components - Set
staleTimeappropriately (don’t rely on default 0) - Use
placeholderData: keepPreviousDatafor pagination to avoid layout shift - Use
enabledflag to conditionally fetch (e.g.,enabled: !!userId) - Colocate query hooks with their API module, not with components
Zustand — Client State Management
Use for: UI state that is shared across components but is NOT server data and is NOT URL state.
Examples: sidebar collapsed, user preferences (before persisted), draft content, complex multi-step form state, feature flags.
Never use for: Server data (use TanStack Query) or navigational state (use URL).
Conventions:
import { create } from "zustand";import { devtools } from "zustand/middleware";
// Slice pattern for large storesinterface SidebarSlice { isCollapsed: boolean; toggleSidebar: () => void;}
interface NotificationSlice { notifications: Notification[]; addNotification: (n: Notification) => void; dismissNotification: (id: string) => void;}
type AppStore = SidebarSlice & NotificationSlice;
export const useAppStore = create<AppStore>()( devtools( (set) => ({ // Sidebar isCollapsed: false, toggleSidebar: () => set((s) => ({ isCollapsed: !s.isCollapsed }), false, "toggleSidebar"),
// Notifications notifications: [], addNotification: (n) => set((s) => ({ notifications: [...s.notifications, n] }), false, "addNotification"), dismissNotification: (id) => set( (s) => ({ notifications: s.notifications.filter((n) => n.id !== id), }), false, "dismissNotification" ), }), { name: "AppStore" } ));
// Always use selectors — prevents unnecessary re-rendersconst isCollapsed = useAppStore((s) => s.isCollapsed);const toggleSidebar = useAppStore((s) => s.toggleSidebar);
// NEVER do this — subscribes to entire storeconst store = useAppStore();Rules:
- Always use selectors (never destructure the whole store)
- Use
devtoolsmiddleware in development for debugging - Name all actions in devtools for traceable state changes
- Use slice pattern when store grows beyond 5-6 properties
- Keep stores small and focused — multiple stores > one mega store
- Zustand actions should be pure transforms — no API calls inside