Chapter 21: Client State Management
Client state is data that exists only in the browser — UI state, user preferences, ephemeral form data, and local-only settings. TanStack Query handles server state. This chapter covers client state with Zustand and React Context.
When to Use What
| State Type | Solution | Example |
|---|---|---|
| Server data (API responses) | TanStack Query | Project list, user profile |
| Global UI state | Zustand | Sidebar collapsed, theme, toast queue |
| Auth session | Zustand (persisted) | Current user, token |
| Low-frequency shared state | React Context | Theme, locale, feature flags |
| Form state | TanStack Form | Input values, validation errors |
| Component-local state | useState/useReducer | Dropdown open, hover state |
Zustand: The Sweet Spot
Zustand is a minimal state management library with:
- No boilerplate (no providers, no reducers, no actions)
- Selector-based subscriptions (components only re-render when their slice changes)
- First-class TypeScript support
- Middleware for persistence, devtools, and immer
Basic Store
// shared/stores/ui-store.ts
import { create } from "zustand";
interface UIState {
sidebarCollapsed: boolean;
toggleSidebar: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
}
export const useUIStore = create<UIState>()((set) => ({
sidebarCollapsed: false,
toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
}));
// In a component — only re-renders when sidebarCollapsed changes
function Sidebar() {
const collapsed = useUIStore((state) => state.sidebarCollapsed);
const toggle = useUIStore((state) => state.toggleSidebar);
return (
<aside className={cn("transition-all", collapsed ? "w-16" : "w-64")}>
<Button variant="ghost" size="icon" onClick={toggle}>
{collapsed ? <ChevronRight /> : <ChevronLeft />}
</Button>
{!collapsed && <SidebarNav />}
</aside>
);
}
Persisted Store
// features/auth/stores/auth-store.ts
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
interface AuthState {
user: User | null;
token: string | null;
setAuth: (user: User, token: string) => void;
clearAuth: () => void;
isAuthenticated: boolean;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
setAuth: (user, token) => set({ user, token }),
clearAuth: () => set({ user: null, token: null }),
get isAuthenticated() {
return get().token !== null;
},
}),
{
name: "auth-storage",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
token: state.token,
user: state.user,
}),
}
)
);
Store with Slices Pattern
For larger stores, split into composable slices:
// shared/stores/app-store.ts
import { create } from "zustand";
// Slice: Sidebar state
interface SidebarSlice {
sidebarCollapsed: boolean;
toggleSidebar: () => void;
}
const createSidebarSlice = (set: any): SidebarSlice => ({
sidebarCollapsed: false,
toggleSidebar: () => set((s: any) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
});
// Slice: Command palette state
interface CommandPaletteSlice {
commandPaletteOpen: boolean;
openCommandPalette: () => void;
closeCommandPalette: () => void;
}
const createCommandPaletteSlice = (set: any): CommandPaletteSlice => ({
commandPaletteOpen: false,
openCommandPalette: () => set({ commandPaletteOpen: true }),
closeCommandPalette: () => set({ commandPaletteOpen: false }),
});
// Combined store
type AppStore = SidebarSlice & CommandPaletteSlice;
export const useAppStore = create<AppStore>()((...args) => ({
...createSidebarSlice(...args),
...createCommandPaletteSlice(...args),
}));
React Context: For Low-Frequency State
Use Context for state that changes rarely and does not need selector-based subscriptions:
// shared/providers/feature-flags-provider.tsx
import { createContext, useContext, type ReactNode } from "react";
interface FeatureFlags {
enableRealTime: boolean;
enableAIAssistant: boolean;
enableBetaFeatures: boolean;
}
const FeatureFlagsContext = createContext<FeatureFlags>({
enableRealTime: false,
enableAIAssistant: false,
enableBetaFeatures: false,
});
export function FeatureFlagsProvider({
flags,
children,
}: {
flags: FeatureFlags;
children: ReactNode;
}) {
return (
<FeatureFlagsContext.Provider value={flags}>
{children}
</FeatureFlagsContext.Provider>
);
}
export function useFeatureFlags() {
return useContext(FeatureFlagsContext);
}
export function useFeatureFlag(flag: keyof FeatureFlags): boolean {
const flags = useFeatureFlags();
return flags[flag];
}
// Usage
function TaskDetail() {
const enableAI = useFeatureFlag("enableAIAssistant");
return (
<div>
{/* ... task content ... */}
{enableAI && <AIAssistantPanel />}
</div>
);
}
⚠️ When NOT to Use Context
React Context re-renders ALL consumers when the value changes. Do not use it for:
- Frequently updating state (form input, mouse position, animation)
- Large objects where components only need a slice
- State that many components consume
For these cases, use Zustand with selectors.
Combining TanStack Query + Zustand
The common pattern: TanStack Query for server data, Zustand for derived client state.
// The auth flow combines both
function useAuth() {
const authStore = useAuthStore();
// Server state: fetch current user profile
const userQuery = useQuery({
queryKey: ["users", "me"],
queryFn: () => AppRuntime.runPromise(UserService.me()),
enabled: authStore.isAuthenticated,
});
const loginMutation = useMutation({
mutationFn: (credentials: LoginInput) =>
AppRuntime.runPromise(AuthService.login(credentials)),
onSuccess: ({ user, token }) => {
// Update client state
authStore.setAuth(user, token);
// Invalidate server state
queryClient.invalidateQueries({ queryKey: ["users", "me"] });
},
});
const logout = () => {
authStore.clearAuth();
queryClient.clear(); // Clear all cached server data
router.navigate({ to: "/login" });
};
return {
user: userQuery.data ?? authStore.user,
isAuthenticated: authStore.isAuthenticated,
isLoading: userQuery.isLoading,
login: loginMutation.mutate,
logout,
};
}
Summary
- ✅ TanStack Query for server state — cached, synchronized, automatically refreshed
- ✅ Zustand for client state — minimal API, selector-based subscriptions, persistence middleware
- ✅ React Context for low-frequency state — theme, locale, feature flags
- ✅ Slice pattern for organizing larger Zustand stores
- ✅ Combine TanStack Query + Zustand: server data in queries, auth/UI state in stores
- ✅ Never use Context for frequently-updating state