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