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.
Use with AI
npx skills add sindev08/react-principles-skills— then invoke /reactprinciples-storePrinciple
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> ); }