# State Management

> State management rules - local vs global state, Context API + useReducer, useSyncExternalStore, Zustand, Redux Toolkit, RTK Query, TanStack Query, Jotai, server state vs client state, state normalization, selectors and derived state, persisted state, middleware, URL as state, Server Components boundaries, testing stores

- Skill: `14bryanespinoza/state-management` (Agent Skill)
- Install (CLI): `npx skillmds@latest add 14bryanespinoza/state-management`
- Raw SKILL.md: https://api.skillmd.com/api/skills/14bryanespinoza/state-management/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: 14BryanEspinoza (https://skillmd.com/u/14bryanespinoza)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/14bryanespinoza/state-management

---


# State Management — Rules and Conventions

---

## 1. Philosophy

1. **Local first** — Prefer `useState`/`useReducer` for component-scoped state. Global only when needed.
2. **Server state ≠ client state** — TanStack Query for server state. Client state for UI, forms, ephemeral data.
3. **Single source of truth** — Normalize entities. Derived state via selectors, not duplicate state.
4. **Immutability by default** — Use Immer (via RTK) or structural sharing. Never mutate directly.
5. **Explicit over implicit** — State transitions via actions/reducers. No magic.
6. **Testable by design** — Pure reducers, pure selectors, injectable stores.

---

## 2. State Taxonomy

| Category         | Examples                                  | Tool                   | Lifetime      |
| ---------------- | ----------------------------------------- | ---------------------- | ------------- |
| **Local**        | `isOpen`, `inputValue`, `hover`           | `useState`             | Component     |
| **UI Global**    | `theme`, `sidebarOpen`, `toasts`          | Context + `useReducer` | Session       |
| **Client Cache** | `formDraft`, `wizardStep`, `filters`      | Zustand / Jotai        | Navigation    |
| **Server State** | `userList`, `postDetail`, `searchResults` | TanStack Query         | Server-driven |
| **URL State**    | `pagination`, `filters`, `sort`           | Router (search params) | Shareable     |

---

## 3. Local vs Global Decision

```text
Is it used by ONE component?          → useState/useReducer (local)
Is it UI state shared by siblings?    → Context + useReducer (UI global)
Is it form/ephemeral shared state?    → Zustand/Jotai (client cache)
Is it fetched from API?               → TanStack Query (server state)
Is it shareable via URL?              → Search params (URL state)
```

### Rules

- **Default to local** — 80% of state is local
- **Global only when proven necessary** — profiling shows prop drilling pain
- **Separate server/client** — never mix in same store

---

## 4. Context API + useReducer (UI Global)

### Pattern

```tsx
// store/uiStore.tsx
import { createContext, useContext, useReducer, ReactNode } from "react";

interface UIState {
  theme: "light" | "dark";
  sidebarOpen: boolean;
  toasts: Toast[];
}

type UIAction =
  | { type: "TOGGLE_THEME" }
  | { type: "TOGGLE_SIDEBAR" }
  | { type: "ADD_TOAST"; payload: Toast }
  | { type: "REMOVE_TOAST"; payload: string };

function uiReducer(state: UIState, action: UIAction): UIState {
  switch (action.type) {
    case "TOGGLE_THEME":
      return { ...state, theme: state.theme === "light" ? "dark" : "light" };
    case "TOGGLE_SIDEBAR":
      return { ...state, sidebarOpen: !state.sidebarOpen };
    case "ADD_TOAST":
      return { ...state, toasts: [...state.toasts, action.payload] };
    case "REMOVE_TOAST":
      return {
        ...state,
        toasts: state.toasts.filter((t) => t.id !== action.payload),
      };
    default:
      return state;
  }
}

const UIContext = createContext<{
  state: UIState;
  dispatch: React.Dispatch<UIAction>;
} | null>(null);

export function UIProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(uiReducer, initialState);
  return (
    <UIContext.Provider value={{ state, dispatch }}>
      {children}
    </UIContext.Provider>
  );
}

export function useUI() {
  const ctx = useContext(UIContext);
  if (!ctx) throw new Error("useUI must be used within UIProvider");
  return ctx;
}
```

### Rules Context API

- **Single context per domain** — don't mix theme + toasts + sidebar
- **`dispatch` stable** — never changes, safe for `useCallback` deps
- **Type-safe actions** — discriminated union for exhaustive switch

---

## 5. Zustand (Client Cache)

### Setup

```tsx
// store/formStore.ts
import { create } from "zustand";
import { persist, devtools } from "zustand/middleware";

interface FormState {
  draft: Partial<UserForm>;
  step: number;
  setField: (key: string, value: unknown) => void;
  setStep: (step: number) => void;
  reset: () => void;
}

export const useFormStore = create<FormState>()(
  devtools(
    persist(
      (set) => ({
        draft: {},
        step: 1,
        setField: (key, value) =>
          set((s) => ({ draft: { ...s.draft, [key]: value } })),
        setStep: (step) => set({ step }),
        reset: () => set({ draft: {}, step: 1 }),
      }),
      {
        name: "user-form",
        partialize: (s) => ({ draft: s.draft, step: s.step }),
      },
    ),
  ),
);
```

### Rules Zustand

- **One store per domain** — `formStore`, `wizardStore`, `filterStore`
- **`persist` middleware** — only for ephemeral client state
- **`devtools` in dev** — `process.env.NODE_ENV !== 'production'`
- **Shallow equality** — `useStore((s) => s.field)` auto-shallow

---

## 6. TanStack Query (Server State)

> **Full patterns**: see `testing` and `api-design` skills.

### Essentials

```tsx
// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

export function useUsers(filters: UserFilters) {
  return useQuery({
    queryKey: ["users", filters],
    queryFn: () => api.getUsers(filters),
    staleTime: 1000 * 60 * 5,
  });
}

export function useCreateUser() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: api.createUser,
    onMutate: async (newUser) => {
      await qc.cancelQueries({ queryKey: ["users"] });
      const previous = qc.getQueryData(["users"]);
      qc.setQueryData(["users"], (old) => [...old, { ...newUser, id: "temp" }]);
      return { previous };
    },
    onError: (_, __, context) => qc.setQueryData(["users"], context?.previous),
    onSettled: () => qc.invalidateQueries({ queryKey: ["users"] }),
  });
}
```

### Rules TankStack

- **`queryKey` as array** — `['users', { page: 1, filter: 'active' }]`
- **`staleTime` > 0** — default 5 min for most data
- **Optimistic updates** — `onMutate` + `onError` rollback
  - `onSettled` invalidate
- **Server state only** — never use for UI state

---

## 7. Jotai (Atomic State)

```tsx
// store/atoms.ts
import { atom, useAtom } from "jotai";

const countAtom = atom(0);
const derivedAtom = atom((get) => get(countAtom) * 2);

export function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const doubled = useAtom(derivedAtom);
  return (
    <button onClick={() => setCount((c) => c + 1)}>
      {count} → {doubled}
    </button>
  );
}
```

### Rules Jotai

- **Atoms for truly global atomic values** — theme, auth, feature flags
- **Derived atoms** — computed from other atoms
- **No provider needed** — works anywhere in tree

---

## 8. State Normalization

### Entity Adapter (RTK)

```ts
// store/usersSlice.ts
import { createEntityAdapter, createSlice } from "@reduxjs/toolkit";

const usersAdapter = createEntityAdapter<User>({
  selectId: (u) => u.id,
  sortComparer: (a, b) => b.createdAt.localeCompare(a.createdAt),
});

const usersSlice = createSlice({
  name: "users",
  initialState: usersAdapter.getInitialState(),
  reducers: {
    addUser: usersAdapter.addOne,
    updateUser: usersAdapter.updateOne,
    removeUser: usersAdapter.removeOne,
    setAll: usersAdapter.setAll,
  },
});
```

### Selectors

```tsx
// selectors/users.ts
import { createSelector } from "@reduxjs/toolkit";

export const selectUsers = (state: RootState) => state.users.entities;
export const selectUserIds = (state: RootState) => state.users.ids;

export const selectUserById = createSelector(
  [
    (state: RootState) => state.users.entities,
    (_: RootState, id: string) => id,
  ],
  (entities, id) => entities[id],
);

export const selectFilteredUsers = createSelector(
  [selectUsers, (state: RootState) => state.filters],
  (users, filters) => Object.values(users).filter((u) => matches(u, filters)),
);
```

### Rules State Normalization

- **Normalize by ID** — `{ [id]: Entity }` not arrays
- **Entity Adapter** — CRUD helpers, memoized selectors
- **Derived via selectors** — never store filtered/sorted copies

---

## 8. Server State vs Client State

| Aspect         | Server State (TanStack Query) | Client State (Zustand/Context) |
| -------------- | ----------------------------- | ------------------------------ |
| **Source**     | API / Database                | User interaction               |
| **Staleness**  | Time-based (`staleTime`)      | Event-based (actions)          |
| **Ownership**  | Server owns truth             | Client owns truth              |
| **Sync**       | Refetch / Invalidate          | Actions / Dispatch             |
| **Optimistic** | `onMutate` + rollback         | Immediate update               |

### Decision

```text
Is the data persisted on a server?     → TanStack Query
Is it user preference / UI state?      → Zustand / Context
Can it be shared via URL?              → Search params (TanStack Router / React Router)
Is it a form draft?                    → Zustand (persist) or local state
```

---

## 9. URL as State

```tsx
// hooks/useUrlFilters.ts
import { useSearchParams } from "react-router-dom"; // or TanStack Router

export function useUrlFilters() {
  const [searchParams, setSearchParams] = useSearchParams();

  const filters = {
    page: Number(searchParams.get("page") || 1),
    sort: searchParams.get("sort") || "newest",
    filter: searchParams.get("filter") || "all",
  };

  const setFilters = (next: Partial<typeof filters>) => {
    const nextParams = new URLSearchParams(searchParams);
    Object.entries(next).forEach(([k, v]) => {
      if (v) nextParams.set(k, String(v));
      else nextParams.delete(k);
    });
    setSearchParams(nextParams);
  };

  return { filters, setFilters };
}
```

### Rules URL as State

- **Shareable state → URL** — filters, pagination, sort
- **Sync with TanStack Query** — `queryKey` includes search params
- **Validate with Zod** — `searchSchema.parse(Object.fromEntries(searchParams))`

---

## 10. Persistence

```tsx
// Zustand persist
persist(
  (set) => ({ ... }),
  {
    name: 'app-storage',
    partialize: (s) => ({ theme: s.theme, recentSearches: s.recentSearches }),
    storage: createJSONStorage(() => localStorage)
  }
)

// TanStack Query persist (experimental)
import { persistQueryClient } from '@tanstack/query-sync-storage-persister'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'

const persister = createSyncStoragePersister({ storage: window.localStorage })
persistQueryClient({ queryClient, persister, maxAge: 1000 * 60 * 60 * 24 })
```

---

## 11. Testing Stores

### Redux/Zustand

```tsx
// store/__tests__/usersSlice.test.ts
import { act } from "@testing-library/react";
import { renderHook, act } from "@testing-library/react";
import { useFormStore } from "../formStore";

it("updates draft and step", () => {
  const { result } = renderHook(() => useFormStore());

  act(() => {
    result.current.setField("name", "Alice");
    result.current.setStep(2);
  });

  expect(result.current.draft.name).toBe("Alice");
  expect(result.current.step).toBe(2);
});
```

### TanStack Query

```tsx
// hooks/__tests__/useUsers.test.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { useUsers } from "../useUsers";

const createWrapper = () => {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return ({ children }) => (
    <QueryClientProvider client={qc}>{children}</QueryClientProvider>
  );
};

it("fetches users", async () => {
  const { result } = renderHook(() => useUsers({}), {
    wrapper: createWrapper(),
  });
  await waitFor(() => expect(result.current.data).toBeDefined());
});
```

---

## 12. Methodology

Before using ANY state management pattern not documented in this skill:

1. **MCP Context7** (priority): `context7_resolve-library-id` + `context7_query-docs` for Zustand, Redux Toolkit, TanStack Query, Jotai.
2. **Official docs**: zustand.docs, redux-toolkit.js.org, tanstack.com/query — verify current APIs.
3. **Project config**: `store/*.ts`, `hooks/*.ts`, `package.json` — verify against actual setup.
4. **HARD RULE**: If not in this skill AND cannot be verified against 2 authoritative sources → DO NOT USE IT. Document as assumption or risk in report to orchestrator.

---

## 13. Prohibitions

- ❌ Do not put server state in Zustand/Context — use TanStack Query
- ❌ Do not put UI state in TanStack Query — use Zustand/Context
- ❌ Do not mutate state directly — `state.x = y` (use Immer or spread)
- ❌ Do not skip `staleTime` in TanStack Query — defaults to 0
- ❌ Do not skip `invalidateQueries` after mutations
- ❌ Do not store derived state — compute via selectors
- ❌ Do not use `useState` for shared state — use Context/Zustand
- ❌ Do not persist sensitive data — tokens, PII in localStorage
- ❌ Do not use Redux for server state — TanStack Query owns that

---

## 14. References

> **Note:** For React patterns (hooks, context), see [React](../reactjs/SKILL.md)
> **Note:** For TypeScript rules, see [TypeScript](../typescript/SKILL.md)
> **Note:** For JavaScript conventions, see [JavaScript](../javascript/SKILL.md)
> **Note:** For TanStack Query patterns, see [Testing](../testing/SKILL.md)
> **Note:** For API Design (server state), see [API Design](../api-design/SKILL.md)
> **Note:** For Next.js Server Components, see [Next.js](../nextjs/SKILL.md)
> **Note:** For Testing patterns, see [Testing](../testing/SKILL.md)

---

Last updated: 2026-08

