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, Server Components fetch server state directly — no React Query or Zustand needed. React Query is for Client Components that fetch after mount.
// ─── SERVER STATE in Server Components ─────────────────────── // fetch() directly — no React Query, no Zustand export default async function UsersPage() { const users = await usersService.getAll(); // Direct async call return <UserList users={users} />; } // ─── SERVER STATE in Client Components ─────────────────────── // Need to fetch after interaction? Use React Query 'use client'; export function UserSearch() { const [query, setQuery] = useState(''); const { data: users } = useQuery({ queryKey: ['users', 'search', query], queryFn: () => usersService.search(query), enabled: query.length > 2, }); // ... } // ─── SHARED STATE ───────────────────────────────────────────── // UI state only — no API data 'use client'; export function Sidebar() { const { sidebarOpen, toggleSidebar } = useAppStore(); return ( <aside className={sidebarOpen ? 'w-64' : 'w-0'}> <button onClick={toggleSidebar}>Toggle</button> </aside> ); }