Skip to content

State Management

FieldValue
TypeAgent Reference
Source~/.copilot/agents/_refs/expert-react-frontend-engineer/state-management.md
DescriptionNot 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 automatically
export const Route = createFileRoute('/users')({
validateSearch: (search) => userFiltersSchema.parse(search),
})
// In the component — fully type-safe, no manual parsing
function 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 level
export 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 configuration
export 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 invalidation
export 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 useQuery directly in components
  • Set staleTime appropriately (don’t rely on default 0)
  • Use placeholderData: keepPreviousData for pagination to avoid layout shift
  • Use enabled flag 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 stores
interface 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-renders
const isCollapsed = useAppStore((s) => s.isCollapsed);
const toggleSidebar = useAppStore((s) => s.toggleSidebar);
// NEVER do this — subscribes to entire store
const store = useAppStore();

Rules:

  • Always use selectors (never destructure the whole store)
  • Use devtools middleware 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