Skip to main content

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 TypeSolutionExample
Server data (API responses)TanStack QueryProject list, user profile
Global UI stateZustandSidebar collapsed, theme, toast queue
Auth sessionZustand (persisted)Current user, token
Low-frequency shared stateReact ContextTheme, locale, feature flags
Form stateTanStack FormInput values, validation errors
Component-local stateuseState/useReducerDropdown 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