Client State with Zustand
Manage global UI state across multiple Zustand stores. Covers store slices, selectors, actions, and a computed filter store with reset.
Principle
Client state — UI toggles, filter state, user preferences — belongs in Zustand, not React Query. Each store owns one domain. Components read a slice of state via selectors and call actions. No prop drilling, no context boilerplate.
One store per feature domain. Never put server state (API data) in Zustand — if it comes from an endpoint, it belongs in React Query.
Rules
- check_circleOne store per domainuseAppStore for app-wide settings, useFilterStore for filters. Never mix concerns in a single store.
- check_circleActions inside the storeMutations happen in store actions, not in component event handlers. Keeps logic close to state.
- check_circleSelectors over full-statePass selector functions: useAppStore(s => s.theme) not useAppStore(). Prevents unnecessary re-renders.
- check_circleReset is first-classAlways define a reset() action for stores that can be cleared. Useful for logout, navigation, and testing.
Pattern
import { create } from 'zustand'; import type { UserRole, UserStatus } from '@/types'; interface FilterState { search: string; role: UserRole | null; status: UserStatus | null; setSearch: (search: string) => void; setRole: (role: UserRole | null) => void; setStatus: (status: UserStatus | null) => void; reset: () => void; } const initialState = { search: '', role: null, status: null }; export const useFilterStore = create<FilterState>((set) => ({ ...initialState, setSearch: (search) => set({ search }), setRole: (role) => set({ role }), setStatus: (status) => set({ status }), reset: () => set(initialState), })); // Computed selector — avoids inline logic in components export const useHasActiveFilters = () => useFilterStore((s) => s.search !== '' || s.role !== null || s.status !== null);
Implementation
Version Compatibility
Requires React 19+ and the latest stable versions of all dependencies shown.
Zustand stores are client-side only. In Next.js, use them inside Client Components marked with 'use client'. No HydrationBoundary needed — client state is not serialized.
'use client'; import { useFilterStore, useHasActiveFilters } from '@/stores/useFilterStore'; export function UserFilters() { const { search, role, setSearch, setRole, reset } = useFilterStore(); const hasFilters = useHasActiveFilters(); return ( <div className="flex gap-3"> <input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search..." /> <select value={role ?? ''} onChange={(e) => setRole(e.target.value || null)}> <option value="">All roles</option> <option value="admin">Admin</option> </select> {hasFilters && <button onClick={reset}>Reset</button>} </div> ); }