| name | web-state-management |
| description | The single biggest cause of stale-data bugs in React apps is putting server state in a global store. Server state — anything fetched from an API — already has a cache manager in TanStack Query (see `tanstack-query-patter |
| source_type | skill |
| source_file | skills/web-state-management.md |
web-state-management
Migrated from skills/web-state-management.md.
Codex packaging notes
- Claude/Telar source files remain the source of truth; this file is the generated Codex adapter.
- Skill-local support files from the original Telar skill, such as
references/... or workflow/..., are packaged beside this SKILL.md.
- Repo-root references from the original Telar file, such as
agents/..., commands/..., scripts/..., resources/..., rules/..., hooks/..., or templates/..., are packaged at this plugin root.
- The original Telar orchestration source (
skills/orchestration/...) is packaged under source/skills/orchestration/... for exact-reference lookups; all other Telar skills exist here only as the generated adapters under the plugin-root skills/ directory.
- Resolve plugin-root paths from this generated skill directory via
../.. when reading support files or running packaged scripts.
Client State Boundaries and the Server-State Rule
The single biggest cause of stale-data bugs in React apps is putting server state in a global store. Server state — anything fetched from an API — already has a cache manager in TanStack Query (see tanstack-query-patterns). Global stores are for genuine client state: UI toggles, wizard step, selected theme, shopping cart draft — things that only exist in the browser and are not the authoritative copy of any backend record.
The Decision List
| State category | Right tool | Wrong tool |
|---|
| Remote data (API response) | TanStack Query | Zustand / Redux |
| UI toggle, wizard step, dialog open | useState / Zustand | TanStack Query |
| Cross-route shared UI state | Zustand | React Context (high-frequency) |
| Low-frequency wide values (theme, locale, auth user) | React Context | Zustand (overkill) |
| Filter / sort / page (shareable URL) | URL search params | Zustand |
| Atomic leaf state updated at high frequency | Jotai / signals | Redux |
| Complex async flows, time-travel devtools, middleware chain | Redux Toolkit | Zustand |
| Local component state not shared | useState / useReducer | Any global store |
Problem
interface ProductStore {
products: Product[]
selectedCategory: string
fetchProducts: () => void
}
const useStore = create<ProductStore & CartStore & UserStore & UIStore>(...)
const AppContext = createContext<{ cart: CartItem[]; setCursor: ... }>()
Solution
The server-state boundary — keep API data in TanStack Query only
export const productKeys = {
all: ['products'] as const,
list: (category: string) => [...productKeys.all, 'list', category] as const,
}
export const useProducts = (category: string) =>
useQuery({
queryKey: productKeys.list(category),
queryFn: () => api.getProducts(category),
staleTime: 5 * 60 * 1000,
})
export const useUIStore = create<{ selectedCategory: string; setCategory: (c: string) => void }>(
(set) => ({
selectedCategory: 'all',
setCategory: (selectedCategory) => set({ selectedCategory }),
})
)
Zustand — slices, typed selectors, persist
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
interface CartState {
items: CartItem[]
addItem: (item: Omit<CartItem, 'quantity'>) => void
removeItem: (id: string) => void
clear: () => void
}
export const useCartStore = create<CartState>()(
persist(
(set) => ({
items: [],
addItem: (item) =>
set((s) => {
const existing = s.items.find((i) => i.id === item.id)
return existing
? { items: s.items.map((i) => (i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i)) }
: { items: [...s.items, { ...item, quantity: 1 }] }
}),
removeItem: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
clear: () => set({ items: [] }),
}),
{ name: 'cart', storage: createJSONStorage(() => localStorage) }
)
)
export const useCartCount = () => useCartStore((s) => s.items.length)
export const useCartTotal = () =>
useCartStore((s) => s.items.reduce((sum, i) => sum + i.price * i.quantity, 0))
URL as state for shareable filters
import { useSearchParams } from 'react-router-dom'
function ProductsPage() {
const [params, setParams] = useSearchParams()
const category = params.get('category') ?? 'all'
const page = Number(params.get('page') ?? '1')
const { data } = useProducts(category)
return (
<>
<CategoryPicker value={category} onChange={(c) => setParams({ category: c, page: '1' })} />
<ProductGrid products={data} page={page} />
</>
)
}
Jotai — atomic, fine-grained state
import { atom, useAtom, useAtomValue } from 'jotai'
const sidebarOpenAtom = atom(false)
const themeModeAtom = atom<'light' | 'dark'>('light')
const isDarkAtom = atom((get) => get(themeModeAtom) === 'dark')
function Sidebar() {
const [open, setOpen] = useAtom(sidebarOpenAtom)
return <aside aria-hidden={!open}>...</aside>
}
Redux Toolkit — when scale justifies it
Reach for Redux Toolkit when: (a) you need time-travel debugging via Redux DevTools, (b) you have complex async middleware (RTK Query, redux-saga), or (c) a large team needs strict action-traceability. For most admin panels and consumer apps, Zustand + TanStack Query is the right default.
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
const uiSlice = createSlice({
name: 'ui',
initialState: { sidebarOpen: false, activeModal: null as string | null },
reducers: {
toggleSidebar: (s) => { s.sidebarOpen = !s.sidebarOpen },
openModal: (s, a: PayloadAction<string>) => { s.activeModal = a.payload },
closeModal: (s) => { s.activeModal = null },
},
})
Why This Works
- Server state has one owner: TanStack Query's cache is the single source of truth for API data. Mutations trigger
invalidateQueries, and every component reading that key automatically gets fresh data — no manual sync step.
- Granular Zustand selectors break re-render chains: subscribing to
(s) => s.items.length instead of (s) => s means Zustand only notifies this component when length changes by reference equality, not on every unrelated mutation.
- URL state is free persistence: filters stored in search params survive refresh, back-navigation, and copy-paste sharing without any store hydration code.
- Jotai atoms are tree-independent: each atom is a standalone reactive cell; derived atoms recompute lazily only when their dependencies change.
Edge Cases & Pitfalls
Common Mistakes
- Syncing server data into Zustand after a fetch: creates a stale second copy. Delete the Zustand field; read directly from
useQuery.
- Inline selector creating a new object:
useStore((s) => ({ a: s.a, b: s.b })) returns a new object on every render. Wrap with useShallow or split into two selectors.
- Persisting everything: do not persist loading states, error objects, or data that TanStack Query already caches. Persist only source-of-truth client values (theme, cart draft, onboarding flags).
- Context for high-frequency state: React Context triggers a full subtree re-render on every value change. Use Zustand or Jotai for anything updated more than a few times per second.
- One giant Zustand store: slicing into feature-scoped stores (
useCartStore, useUIStore) limits subscription scope and makes each slice independently testable.
Verification
npm run dev
References