State Management — Rules and Conventions
1. Philosophy
- Local first — Prefer
useState/useReducerfor component-scoped state. Global only when needed. - Server state ≠ client state — TanStack Query for server state. Client state for UI, forms, ephemeral data.
- Single source of truth — Normalize entities. Derived state via selectors, not duplicate state.
- Immutability by default — Use Immer (via RTK) or structural sharing. Never mutate directly.
- Explicit over implicit — State transitions via actions/reducers. No magic.
- 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
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
// 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
dispatchstable — never changes, safe foruseCallbackdeps- Type-safe actions — discriminated union for exhaustive switch
5. Zustand (Client Cache)
Setup
// 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 persistmiddleware — only for ephemeral client statedevtoolsin dev —process.env.NODE_ENV !== 'production'- Shallow equality —
useStore((s) => s.field)auto-shallow
6. TanStack Query (Server State)
Full patterns: see
testingandapi-designskills.
Essentials
// 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
queryKeyas array —['users', { page: 1, filter: 'active' }]staleTime> 0 — default 5 min for most data- Optimistic updates —
onMutate+onErrorrollbackonSettledinvalidate
- Server state only — never use for UI state
7. Jotai (Atomic State)
// 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 => 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)
// 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
// 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
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
// 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 —
queryKeyincludes search params - Validate with Zod —
searchSchema.parse(Object.fromEntries(searchParams))
10. Persistence
// 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
// 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
// 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:
- MCP Context7 (priority):
context7_resolve-library-id+context7_query-docsfor Zustand, Redux Toolkit, TanStack Query, Jotai. - Official docs: zustand.docs, redux-toolkit.js.org, tanstack.com/query — verify current APIs.
- Project config:
store/*.ts,hooks/*.ts,package.json— verify against actual setup. - 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
staleTimein TanStack Query — defaults to 0 - ❌ Do not skip
invalidateQueriesafter mutations - ❌ Do not store derived state — compute via selectors
- ❌ Do not use
useStatefor 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 Note: For TypeScript rules, see TypeScript Note: For JavaScript conventions, see JavaScript Note: For TanStack Query patterns, see Testing Note: For API Design (server state), see API Design Note: For Next.js Server Components, see Next.js Note: For Testing patterns, see Testing
Last updated: 2026-08