state-management
Skill from vincentbollaert/turborepo-monorepo
- Category: discovered
- Tags: github, community
- Version: 1.0.0
- Author: walid
- Downloads: 0
- GitHub stars: 2
- Updated: 2025-11-21
Download the latest version (v1.0.0) from this page.
SKILL.md
# State Management Patterns
**Auto-detection:** Deciding between React Query vs Zustand vs useState, server state vs client state, global state management, Context misuse
**When to use:**
- Deciding between React Query, Zustand, or useState for a use case
- Setting up Zustand for shared UI state (modals, sidebars, preferences)
- Managing forms with React Hook Form + Zod
- Understanding when NOT to use Context for state management
**Key patterns covered:**
- Server state = React Query ONLY (never store server data in Zustand)
- Client state = useState (local) or Zustand (shared, 2+ components)
- Context for dependency injection only (NEVER for state management)
- URL params for shareable/bookmarkable state (filters, search)
---
# State Management
> **Quick Guide:** Server data? React Query (§). Local UI? useState (§1). Shared UI? Zustand (§2). Singleton/DI? Context (§). **NEVER use Context for state management!**
---
## Server State vs Client State
**STRICT SEPARATION REQUIRED:**
**Server State = React Query ONLY**
- Data from APIs (users, products, posts, etc.)
- Database records
- Remote data of any kind
- **Use OpenAPI-generated React Query hooks** from `@repo/api/reactQueries`
- Automatic caching, revalidation, and synchronization
**Client State = Local (useState) OR Global (Zustand) based on scope**
**RULE: Use local state ONLY when state is truly component-local. As soon as state is needed in multiple places, use global state management.**
**Local State (React.useState):**
- ✅ Component-local UI state (isExpanded, isOpen, selectedIndex)
- ✅ State used ONLY within a single component
- ✅ Temporary UI state that doesn't need to be shared
- ✅ Form input values (if form is self-contained)
**Global State (Zustand/Context):**
- ✅ State needed by multiple components across the tree
- ✅ Modal open/closed state (accessed from multiple places)
- ✅ Sidebar collapsed state (header + sidebar need it)
- ✅ User preferences (theme, language, layout settings)
- ✅ Shopping cart, filters, selections shared across routes
- ✅ Any state that needs to be accessed from 2+ disconnected components
**Decision tree when you need to share state:**
1. **Is it server data?** → Use React Query ✅
2. **Is it URL-appropriate?** → Use URL params (Next.js searchParams) ✅
3. **Is it needed in 2+ components?** → Use Zustand ✅
4. **Is it truly component-local?** → Use useState ✅
5. **Is it a singleton/dependency?** → Use Context (ONLY for DI, not state) ✅
**NEVER: "Should I use Context for state management?" → NO. Use Zustand.**
**CRITICAL RED FLAGS:**
- ❌ **NEVER store server data in client state (useState, Context, Zustand)**
- ❌ **NEVER store UI state in React Query**
- ❌ Duplicating server data in client state
- ❌ Syncing server data manually to client state
- ❌ Using useState for state needed in multiple components (lift to Zustand instead)
- ❌ Prop drilling 3+ levels (use Zustand instead)
---
## React Query Patterns (OpenAPI-Generated)
**MANDATORY: Use OpenAPI-generated React Query hooks**
- API client generated from `packages/api/openapi.yaml`
- React Query hooks auto-generated via `@hey-api/openapi-ts`
- Import query options from `@repo/api/reactQueries`
- **DO NOT write custom query hooks** - use generated ones
- Query keys managed automatically by generated code
**Pattern:**
```typescript
// ✅ Import generated query options
import { getFeaturesOptions } from "@repo/api/reactQueries";
// ✅ Use with useQuery
const { data, isPending, error, isSuccess } = useQuery(getFeaturesOptions());
```
**QueryClient Configuration:**
```typescript
// Centralized configuration with shared defaults
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false, // Disable retries during development with MSW
},
},
});
```
**Loading and Error State Handling:**
- **Always handle all states explicitly**: `isPending`, `isSuccess`, `error`
- Use `Skeleton` component for loading states
- Use `Info` component for error and empty states
- Never leave states unhandled
**RED FLAGS:**
- ❌ Writing custom useQuery hooks (use generated options)
- ❌ Manual query key management (let OpenAPI generate)
- ❌ Missing error handling
- ❌ No loading states (must use Skeleton)
- ❌ Not using generated API client
- ❌ Importing API functions directly instead of using generated hooks
---
## Client State Management (Local vs Global)
**RULE: Local state by default. Global state as soon as it's shared.**
### Local State (useState)
**Use ONLY when state is truly component-local**
**When to use useState:**
- ✅ State used ONLY in one component (isExpanded, isOpen)
- ✅ Temporary UI state that never needs to be shared
- ✅ Form input values (if form is self-contained)
**When NOT to use useState:**
- ❌ State needed in 2+ components (use Zustand)
- ❌ Prop drilling 3+ levels (use Zustand)
- ❌ Server data (use React Query)
**Example:**
```typescript
// ✅ Truly local state - only used in this component
export const Feature = ({ id, title, status, description }: FeatureProps) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
<li onClick={() => setIsExpanded((prev) => !prev)} data-expanded={isExpanded}>
{/* ... */}
</li>
);
};
```
### Global State (Zustand)
**Use as soon as state is needed in multiple places**
**When to use Zustand:**
- ✅ State needed by 2+ components across the tree
- ✅ Modal state (trigger from header, render in layout)
- ✅ Sidebar collapsed (header button + sidebar component)
- ✅ User preferences (theme, language, layout)
- ✅ Shopping cart, filters, selected items
- ✅ Any shared UI state
**Example:**
```typescript
// stores/ui-store.ts
import { create } from 'zustand';
interface UIState {
sidebarOpen: boolean;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
}
export const useUIStore = create<UIState>((set) => ({
sidebarOpen: true,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
}));
// Usage in Header
function Header() {
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
return <button onClick={toggleSidebar}>Toggle</button>;
}
// Usage in Sidebar
function Sidebar() {
const isOpen = useUIStore((state) => state.sidebarOpen);
return <aside data-open={isOpen}>...</aside>;
}
```
**Zustand Patterns:**
- **Prefer separate selectors** for best performance (each component selects only what it needs)
- Use `shallow` only when destructuring multiple values into an object
- Keep stores focused (ui-store, cart-store, not app-store)
- Use middleware: `persist` for localStorage, `devtools` for debugging
**Selector approaches:**
```typescript
// ✅ BEST: Separate selectors (no shallow needed)
const sidebarOpen = useUIStore((state) => state.sidebarOpen);
const theme = useUIStore((state) => state.theme);
// ✅ GOOD: Shallow when destructuring multiple values
import { shallow } from "zustand/shallow";
const { sidebarOpen, theme } = useUIStore(
(state) => ({ sidebarOpen: state.sidebarOpen, theme: state.theme }),
shallow,
);
```
**RED FLAGS:**
- ❌ Using useState for shared state (causes prop drilling)
- ❌ Using Context for frequently updating state (performance issues)
- ❌ Storing server data in Zustand (use React Query)
- ❌ Creating unnecessary object references in selectors (causes re-renders)
---
## Context API - NOT for State Management
**CRITICAL: Context is NOT a state management solution. It's for dependency injection and singletons ONLY.**
**ONLY use Context for:**
- ✅ Framework providers (QueryClientProvider, Router, etc.)
- ✅ Dependency injection (services, API clients, DB connections)
- ✅ Singletons that NEVER or RARELY change (theme configuration, i18n)
- ✅ Values that are set once at app initialization
**NEVER use Context for:**
- ❌ **ANY state management** (use Zustand instead)
- ❌ **ANY frequently updating values** (massive performance issues)
- ❌ Server data (use React Query)
- ❌ UI state (use Zustand for shared, useState for local)
- ❌ User interactions, selections, filters (use Zustand)
- ❌ Shopping carts, modals, sidebars (use Zustand)
**Why:** Every consumer re-renders on ANY value change. No selectors. Performance issues at scale.
**Pattern (Framework Setup ONLY):**
```typescript
// ✅ Context for framework providers - values NEVER change
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
```
**❌ NEVER do this:**
```typescript
// ❌ NEVER use Context for state management
const UIContext = createContext();
function UIProvider({ children }) {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [theme, setTheme] = useState('light');
// ❌ Every consumer re-renders on ANY change!
return (
<UIContext.Provider value={{ sidebarOpen, modalOpen, theme, ... }}>
{children}
</UIContext.Provider>
);
}
// ✅ Use Zustand instead
const useUIStore = create((set) => ({
sidebarOpen: true,
modalOpen: false,
theme: 'light',
// ... actions
}));
```
**RED FLAGS:**
- ❌ **Context with useState/useReducer for state management**
- ❌ Context values that change based on user interactions
- ❌ Multiple pieces of state in one context
- ❌ Context for shopping cart, filters, selections
- ❌ "Splitting contexts" to fix performance (just use Zustand!)
---
## URL State for Shareable Filters
**PATTERN: Use URL params for shareable/bookmarkable state**
- Filter selections
- Search queries
- Pagination state
- Sort order
- Any state that should be shareable via URL
**Benefits:** Shareable URLs, browser navigation, bookmarkable, SEO-friendly.
**Example:**
```typescript
// Next.js App Router
const searchParams = useSearchParams();
const category = searchParams.get("category");
const search = searchParams.get("search");
```
**RED FLAGS:**
- ❌ Filter state in useState (not shareable)
- ❌ Not syncing URL params with React Query keys
---
# State Management - Examples
---
## Server State vs Client State
### ✅ Example: React Query with OpenAPI-Generated Hooks (Actual Pattern)
```typescript
// apps/client-next/app/features.tsx
import { useQuery } from "@tanstack/react-query";
import { getFeaturesOptions } from "@repo/api/reactQueries"; // ✅ Generated from OpenAPI
import { Feature } from "@repo/ui/feature";
import { Info } from "@repo/ui/info";
import { Skeleton } from "@repo/ui/skeleton";
import { Shell } from "./shell";
import styles from "./features.module.scss";
// ✅ GOOD: Server state managed by React Query with generated hooks
export const Features = () => {
// ✅ Use generated query options
const { isSuccess, isPending, error, data } = useQuery(getFeaturesOptions());
// ✅ Handle loading state
if (isPending) {
return (
<Shell>
{Array(7)
.fill("")
.map((_, index) => (
<div className={styles.row} key={index}>
<Skeleton className={styles.skeletonCircle} />
<Skeleton />
</div>
))}
</Shell>
);
}
// ✅ Handle empty state
if (isSuccess && !data.features?.length) {
return (
<Shell>
<Info variant="info" message="No features found" />
</Shell>
);
}
// ✅ Handle error state
if (error) {
return (
<Shell>
<Info variant="error" message={`An error has occurred: ${error}`} />
</Shell>
);
}
// ✅ Render success state
return (
<Shell>
{data?.features?.map((feature) => (
<Feature key={feature.id} {...feature} />
))}
</Shell>
);
};
```
**OpenAPI Setup:**
```typescript
// packages/api/openapi-ts.config.ts
export default defineConfig({
input: "./openapi.yaml",
output: {
format: "prettier",
lint: "eslint",
path: "./src/apiClient",
},
plugins: [
...defaultPlugins,
"@hey-api/client-fetch",
"@tanstack/react-query", // ✅ Generates React Query hooks
],
});
```
**QueryClient Configuration:**
```typescript
// apps/client-next/app/providers.tsx
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false, // ✅ Disable retries during development with MSW
},
},
});
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
```
### ❌ BAD: Managing Server State in useState
```typescript
// ❌ DON'T DO THIS!
function FeaturesList() {
const [features, setFeatures] = useState<Feature[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
fetch("/api/features")
.then((res) => res.json())
.then(setFeatures)
.catch(setError)
.finally(() => setLoading(false));
}, []);
// ❌ No caching, no automatic refetch, manual error handling, etc.
}
```
**Why React Query + Generated Hooks:**
- ✅ Automatic caching and revalidation
- ✅ Type-safe API calls from OpenAPI schema
- ✅ Automatic React Query hooks generation
- ✅ No manual query key management
- ✅ Background refetch and sync
- ✅ Simplified error and loading states
---
### ✅ Example: Local State - Component-Only (Actual Pattern)
```typescript
// packages/ui/src/patterns/feature/feature.tsx
import { useState } from "react";
import clsx from "clsx";
import { Switch } from "@radix-ui/react-switch";
import styles from "./feature.module.scss";
export type FeatureProps = {
id: string;
title: string;
status: string;
description: string;
};
// ✅ GOOD: Truly local state - only used in this component
export const Feature = ({ id, title, status, description }: FeatureProps) => {
// ✅ This state is NEVER needed outside this component
const [isExpanded, setIsExpanded] = useState(false);
return (
<li
className={clsx(styles.feature)}
onClick={() => setIsExpanded((expandedState) => !expandedState)}
data-expanded={isExpanded}
data-testid="feature"
>
<Switch
id={`${id}-switch`}
className={styles.switch}
checked={status === "done"}
onClick={(event) => {
event.stopPropagation();
}}
/>
<div>
<strong>{title}</strong>
{isExpanded && <p>{description}</p>}
</div>
</li>
);
};
```
### ❌ Example: When Local State is NOT Enough
```typescript
// ❌ BAD: Prop drilling for shared state
function App() {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<Layout sidebarOpen={sidebarOpen}>
<Header onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} />
<Sidebar isOpen={sidebarOpen} />
<Content />
</Layout>
);
}
// ✅ GOOD: Use Zustand for shared state
import { create } from 'zustand';
const useUIStore = create<UIState>((set) => ({
sidebarOpen: true,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
}));
function Header() {
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
return <button onClick={toggleSidebar}>Toggle</button>;
}
function Sidebar() {
const isOpen = useUIStore((state) => state.sidebarOpen);
return <aside data-open={isOpen}>...</aside>;
}
```
### ✅ Example: Global State with Zustand (Recommended Pattern)
```typescript
// stores/ui-store.ts
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
interface UIState {
sidebarOpen: boolean;
modalOpen: boolean;
theme: 'light' | 'dark';
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
openModal: () => void;
closeModal: () => void;
setTheme: (theme: 'light' | 'dark') => void;
}
// ✅ GOOD: Global UI state in Zustand
export const useUIStore = create<UIState>()(
devtools(
persist(
(set) => ({
sidebarOpen: true,
modalOpen: false,
theme: 'light',
toggleSidebar: () =>
set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarOpen: (open) =>
set({ sidebarOpen: open }),
openModal: () =>
set({ modalOpen: true }),
closeModal: () =>
set({ modalOpen: false }),
setTheme: (theme) =>
set({ theme }),
}),
{
name: 'ui-storage',
partialize: (state) => ({ theme: state.theme }), // Only persist theme
}
)
)
);
```
### ✅ Example: Using Zustand with Shallow Comparison
```typescript
import { shallow } from 'zustand/shallow';
// ❌ BAD: Will re-render on ANY store change
function Header() {
const { sidebarOpen, modalOpen, theme } = useUIStore();
return <header>...</header>;
}
// ✅ GOOD: Only re-renders when selected values change
function Header() {
const { sidebarOpen, modalOpen, theme } = useUIStore(
(state) => ({
sidebarOpen: state.sidebarOpen,
modalOpen: state.modalOpen,
theme: state.theme,
}),
shallow
);
return <header>...</header>;
}
// ✅ ALTERNATIVE: Select only what you need
function Header() {
const sidebarOpen = useUIStore((state) => state.sidebarOpen);
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
return <header>...</header>;
}
```
**Why Zustand for Shared State:**
- ✅ Simple API, minimal boilerplate
- ✅ No prop drilling
- ✅ TypeScript support
- ✅ DevTools integration
- ✅ Persistence middleware
- ✅ Fast and performant
- ✅ Works with React Server Components
**When to introduce Zustand:**
- State needed in 2+ disconnected components
- Prop drilling more than 2 levels
- State that should persist (with persist middleware)
- Complex UI state management
---
## React Query Patterns
### ✅ Example: Using Generated Query Options (Actual Pattern)
```typescript
// ✅ Generated query options from OpenAPI
// packages/api/src/apiClient/@tanstack/react-query.gen.ts
export const getFeaturesOptions = () => ({
queryKey: ["features"], // ✅ Generated query key
queryFn: async () => {
const res = await client.GET("/features");
return res.data;
},
});
```
```typescript
// ✅ Usage in component
import { useQuery } from "@tanstack/react-query";
import { getFeaturesOptions } from "@repo/api/reactQueries";
export const Features = () => {
// ✅ Just use the generated options
const { data, isPending, error, isSuccess } = useQuery(getFeaturesOptions());
// ✅ Handle all states...
};
```
---
## Client State Management
### Decision Tree: Local vs Global State
```typescript
// Is the state server data?
// → YES: Use React Query ✅
// Is the state needed in 2+ components?
// → YES: Use Zustand ✅
// → NO: Use useState ✅
// Does state need to persist across sessions?
// → YES: Use Zustand with persist middleware ✅
// Is state causing 3+ levels of prop drilling?
// → YES: Use Zustand ✅
```
### ❌ Example: DON'T Store Server Data in Client State
```typescript
// ❌ BAD: Server data in useState
function FeaturesList() {
const [features, setFeatures] = useState<Feature[]>([]);
useEffect(() => {
fetch("/api/features")
.then((res) => res.json())
.then(setFeatures);
}, []);
}
// ❌ BAD: Server data in Zustand
const useFeatureStore = create((set) => ({
features: [],
fetchFeatures: async () => {
const res = await fetch("/api/features");
const data = await res.json();
set({ features: data });
},
}));
// ✅ GOOD: Server data in React Query
function FeaturesList() {
const { data } = useQuery(getFeaturesOptions());
return data?.features?.map((f) => <Feature {...f} />);
}
```
---
## Context API - NOT for State Management
**Context is acceptable for framework providers** (e.g., QueryClientProvider for React Query, ThemeProvider for themes) **because they provide singletons that never change during runtime. Do NOT use Context for state management.**
### ❌ E