State Management Decisions
Purpose
Classify each piece of state by source and place it in exactly one home. Most "state management problems" are actually misclassification problems — server state in a global store, UI state duplicated across components, derivable values stored as state.
Universal — the source-classification model (derivable / URL / server / local / shared) applies to any reactive framework; only the tool names per category differ.
Procedure
Classify every piece of state by source
Source What to do Example Derivable Don't store — compute from input fullNamefromfirstName + lastNameURL-appropriate Sync to URL query params filters, tabs, page number, modal open state for shareable links Server data Server-state cache library API responses, cached lists Local component Framework's local state primitive form draft, hover state, single-component UI Shared / global Cross-component store with selector pattern theme, current user object, cross-page UI state - Buckets compose — don't force a whole feature into one: a search page's query is URL state that drives a server fetch; classify each layer (the query string vs. the fetched results) to its own home.
Detect derivable state — the highest-leverage fix (eliminated state can't desync)
- Two patterns to find: (a) an effect that writes state derived from props/other state (
useEffect.*setX), and (b) a state value that merely mirrors a prop or is recomputed each render - Replace with a computed value (memoize only if the computation is genuinely expensive)
- Two patterns to find: (a) an effect that writes state derived from props/other state (
Identify missing URL state
- Tabs, filters, pagination, sort order, modal open/close (when shareable matters)
- Symptom: refreshing the page loses the user's place
- Fix: lift to URL via
nuqsoruseSearchParams
Identify server-state-in-global-store
- Anti-pattern:
useStoreholdsusers[]fetched from API - Why bad: cache invalidation becomes your problem, no built-in stale/loading state
- Fix: move to TanStack Query / SWR / Apollo
- Anti-pattern:
Collapse related local-state atoms into one unit
- 3+ interdependent local-state values in one component → consolidate into a single reducer (or a small local store)
- Especially for wizards and complex async sequences
- For forms specifically: prefer a form library + schema validation over a hand-rolled reducer once you have > 3 fields or any validation (see
form-uxskill)
Move frequently-changing shared values off a broad-subscription primitive
- A shared value that changes often and is read by many components causes excessive re-renders if it lives in a broad-subscription context
- Move it to a selector-based store so each consumer subscribes only to the slice it needs
- Keep broad context for rarely-changing values: theme, locale, auth identity
Apply optimistic UI for instant-feedback mutations
- Mutations where the user expects immediate feedback (like-button, comment-post, drag-reorder)
- Render the optimistic result immediately; roll back on server error
Document decisions
- For non-trivial state: comment why this tool was chosen
- Optionally: ADR for the global state architecture
Completion Criteria
- Derivable state = 0 (no
useEffectsyncing derived values) - URL-appropriate state lives in URL (refresh-resilient)
- Prop drilling depth ≤ 2 (deeper → lift to a shared store or context)
- Server state in a server-state cache library (TanStack Query / SWR / Apollo), not a general client store
- State classification recorded for non-trivial cases
Output
- Refactored state code: each piece classified and moved to its correct home (URL / server / local / shared)
- ADR:
docs/adr/ADR-NNN-state-management-strategy.mdif introducing or changing the global store choice (seedecision-recordsskill) - Inline comments:
// state: [server|url|local|shared] because [reason]at non-obvious state declarations - PR description block:
- Derived state eliminated: N - URL state added: N (filter / tab / page / modal) - Server state moved to TanStack/SWR: N - Prop drilling fixed (max depth: was N → now ≤ 2)
Implementation
React + Next.js (default)
| Source | Tool |
|---|---|
| URL | nuqs (Next.js) or useSearchParams |
| Server | TanStack Query / SWR / Apollo |
| Local | useState / useReducer |
| Shared | Zustand (with selectors) |
| Optimistic mutation | useOptimistic (React 19) |
Other stacks
- Vue / Nuxt: URL →
useRoute().query+definePageMeta; Server → TanStack Query (Vue) / VueUseuseFetch; Local →ref/reactive; Shared → Pinia (withdefineStore+ getters acting as selectors) - SvelteKit: URL →
$page.url.searchParams+goto(); Server → TanStack Query (Svelte) / SvelteKit+page.tsload; Local → Svelte 5$staterunes; Shared → Svelte stores or context API - Angular: URL →
Router+ActivatedRoute.queryParams; Server → TanStack Query (Angular) or RxJS service; Local → component property / signal; Shared → service with signals or NgRx for complex cases - Universal anti-pattern: server state in a global store. Use a server-state library — every modern framework has TanStack Query bindings.
Related skills
form-ux— for form state specifically (use react-hook-form over useReducer)api-caching-optimization— when the state is actually server dataarchitecture-improvement— for cross-module state placement
Reference
- Key insight encoded: Classify state by source before picking a tool. Server state → TanStack Query (not Zustand — TanStack handles cache/stale/loading for free). URL state → nuqs (shareable, refresh-resilient). Local complex (forms/wizards) → useReducer. Cross-component client → Zustand. Reaching for a global store before this triage is an architectural smell.