State Taxonomy
Three categories of state — local, shared, and server — and exactly which tool handles each one.
Principle
Not all state is the same. Before reaching for any state management library, ask one question: where does this data come from? Local state lives inside one component. Shared state is UI state needed by multiple components. Server state comes from an API and has its own lifecycle — loading, error, stale, and needs refreshing. Each category has a different tool, and mixing them up causes bugs that are hard to trace.
When you find yourself putting API data into Zustand, stop. Server state belongs in React Query. When you find yourself using React Query for a toggle or a modal, stop. UI state belongs in useState or Zustand.
Rules
- check_circleLocal state: useStateIf only one component needs it, keep it local. A form input value, a toggle, a hover state — these are all local state.
- check_circleShared state: ZustandIf multiple components need the same UI state — sidebar open/closed, active theme, search dialog open — use Zustand. This is not server data.
- check_circleServer state: React QueryIf it comes from an API, it is server state. React Query handles caching, background refetching, loading states, and error states automatically.
- check_circleNever put server state in ZustandStoring API data in Zustand means you manage caching, staleness, and loading manually. React Query already does this — use the right tool.
Pattern
// ─── LOCAL STATE ────────────────────────────────────────────── // One component needs it. No sharing needed. const [isOpen, setIsOpen] = useState(false); const [inputValue, setInputValue] = useState(''); const [hovering, setHovering] = useState(false); // ─── SHARED STATE (Zustand) ─────────────────────────────────── // Multiple components need the same UI state. // This is NOT data from an API. const { sidebarOpen, toggleSidebar } = useAppStore(); const { theme, setTheme } = useAppStore(); const { open: searchOpen } = useSearchStore(); // ─── SERVER STATE (React Query) ─────────────────────────────── // Comes from an API. Has loading, error, and cache lifecycle. const { data: users, isLoading, error } = useUsers(); const { data: user } = useUser(id); // ❌ WRONG — API data in Zustand const useUserStore = create((set) => ({ users: [], fetchUsers: async () => { const data = await usersService.getAll(); // ← belongs in React Query set({ users: data }); }, })); // ✅ RIGHT — API data in React Query, UI state in Zustand const { data: users } = useUsers(); // React Query const { activeFilter } = useFilterStore(); // Zustand
Implementation
Version Compatibility
Requires React 19+ and the latest stable versions of all dependencies shown.
In Next.js App Router, all three state categories are demonstrated in the starter template. See: github.com/sindev08/react-principles-nextjs → src/shared/stores/ and src/features/users/hooks/
// ─── SHARED STATE (Zustand) — src/shared/stores/ ───────────── // useAppStore: theme + sidebar (app-wide UI) 'use client'; export const useAppStore = create<AppState>((set) => ({ theme: 'light', sidebarOpen: true, toggleTheme: () => set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' })), toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })), })); // useFilterStore: search + role + status filters with reset // useSearchStore: search dialog open/closed // ─── SERVER STATE (React Query) — src/features/users/hooks/ ── // useUsers: paginated user list from DummyJSON 'use client'; export function useUsers(params?: { limit?: number; skip?: number }) { return useQuery({ queryKey: queryKeys.users.list(params ?? {}), queryFn: () => usersService.getAll(params), // service → hook → component }); } // useUser(id): single user detail via usersService.getById // useCreateUser: mutation via usersService.create + cache invalidation
View stores in starter
View the real implementation in react-principles-nextjs