Client State with Zustand
Manage global UI state across multiple Zustand stores. Covers selectors, actions, computed selectors, reset, and the 'use client' boundary in Next.js.
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, useSearchStore for search UI. 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(). For multiple values, use useShallow from zustand/shallow.
- check_circleReset is first-classAlways define a reset() action for stores that can be cleared. Useful for logout, navigation, and testing.
- check_circle'use client' on the store fileZustand hooks call React internals (useState, useSyncExternalStore). Put 'use client' on the store file itself — never on barrel exports — so Server Components can still import types.
Pattern
'use client'; import { create } from 'zustand'; import type { UserRole, UserStatus } from '@/shared/types/common'; // ─── useFilterStore (feature-scoped filters) ───────────────────────────────── 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 initialFilterState = { search: '', role: null as UserRole | null, status: null as UserStatus | null, }; export const useFilterStore = create<FilterState>((set) => ({ ...initialFilterState, setSearch: (search) => set({ search }), setRole: (role) => set({ role }), setStatus: (status) => set({ status }), reset: () => set(initialFilterState), })); export const useHasActiveFilters = () => useFilterStore( (s) => s.search !== '' || s.role !== null || s.status !== null, ); // ─── useAppStore (app-wide settings) ───────────────────────────────────────── type Theme = 'light' | 'dark'; interface AppState { theme: Theme; sidebarOpen: boolean; setTheme: (theme: Theme) => void; toggleTheme: () => void; setSidebarOpen: (open: boolean) => void; toggleSidebar: () => void; } export const useAppStore = create<AppState>((set) => ({ theme: 'dark', sidebarOpen: true, setTheme: (theme) => set({ theme }), toggleTheme: () => set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })), setSidebarOpen: (sidebarOpen) => set({ sidebarOpen }), toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), })); // ─── useSearchStore (search dialog UI) ─────────────────────────────────────── interface SearchState { open: boolean; setOpen: (open: boolean) => void; toggle: () => void; } export const useSearchStore = create<SearchState>()((set) => ({ open: false, setOpen: (open) => set({ open }), toggle: () => set((s) => ({ open: !s.open })), }));
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 useShallow when reading multiple values to avoid unnecessary re-renders.
'use client'; import { useShallow } from 'zustand/shallow'; import { useFilterStore, useHasActiveFilters } from '@/shared/stores/useFilterStore'; import { Input } from '@/ui/Input'; import { NativeSelect } from '@/ui/NativeSelect'; import type { UserRole } from '@/shared/types/common'; export function UserFilters() { const { search, role, setSearch, setRole, reset } = useFilterStore( useShallow((s) => ({ search: s.search, role: s.role, setSearch: s.setSearch, setRole: s.setRole, reset: s.reset, })), ); const hasFilters = useHasActiveFilters(); return ( <div className="flex items-end gap-3"> <Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search users..." /> <NativeSelect value={role ?? ''} onChange={(e) => setRole((e.target.value || null) as UserRole | null) } > <option value="">All roles</option> <option value="admin">Admin</option> <option value="editor">Editor</option> <option value="viewer">Viewer</option> </NativeSelect> {hasFilters && ( <button onClick={reset}>Reset</button> )} </div> ); }