State Management skill for Claude Code
One owner per value: URL, cache, store or component. Instead of reaching for a global store by default, Claude classifies every value by lifetime and audience and assigns one owner: TanStack Query for server data, the URL for shareable filters and tabs, component state for local UI, and a small Zustand or Redux store for what remains. It hunts down effects that copy fetched data into state and produces an audit table of moves.
- name
state-management - size195 lines · ≈2,050 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Decides where each piece of frontend state belongs — component state, URL search params, server cache (TanStack Query, SWR, RTK Query), or a global client store (Zustand, Redux Toolkit, Jotai, Pinia) — and removes duplicated or derived state that causes sync bugs. Use when choosing a state library, when server data is copied into a store, when filters or tabs are lost on refresh or share, when a global store has grown into a dumping ground, or when two sources of truth disagree. Try asking
- Our Redux store holds every API response and keeps going stale — restructure it
- Filters on the orders page reset when I refresh or share the link
- Should this app use Zustand, Jotai or just React Query?
Use it when
- Picking a state library for a new frontend
- API data is mirrored into a global store or useState
- UI state should survive refresh or be shareable by link
- Two parts of the UI show different values for the same thing
Skip it when
- The issue is effects and derived state inside one component — use react-patterns
- Data must work offline and sync later on mobile — use offline-sync
How to check the result
Refreshing, sharing the URL, back/forward navigation and switching users all show the correct state, and devtools show no entity lists held in both cache and store.
The common mistake it prevents
Copying query results into a store to make them editable, creating a second source of truth that silently diverges after the next refetch.
State is managed well when every value has exactly one owner, the owner matches the value’s lifetime and audience, server data lives in a cache that knows it can go stale, and nothing that can be derived is stored.
Classify first
For each value in the feature, answer these in order. The first “yes” decides.
| Question | Owner | Typical tools |
|---|---|---|
| Does it come from the server and can change without this user? | Server cache | TanStack Query, SWR, RTK Query, framework loaders, Apollo/urql |
| Should it survive refresh, be shareable, or work with back/forward? | URL (path or search params) | Router params, useSearchParams, nuqs, SvelteKit url, Vue Router query |
| Can it be computed from other state? | Nobody — derive in render | computed, $derived, plain expressions |
| Is it a form’s in-progress input? | Form state | Native form + FormData, React Hook Form, Superforms |
| Used by one component or a small subtree? | Local component state | useState/useReducer, ref, $state |
| Must it persist across sessions on this device? | Storage-backed store | Zustand persist, localStorage wrapper, IndexedDB |
| Shared by distant components, client-only, changes often? | Global client store | Zustand, Jotai, Redux Toolkit, Pinia, Svelte .svelte.ts state |
| Shared, rarely changes (theme, locale, user shape)? | Context / provide-inject | React context, Vue provide, Svelte setContext |
Global client stores should end up small: UI preferences, cart drafts, multi-step wizard progress, real-time session state. If a store holds lists of entities fetched from an API, it is acting as a bad cache.
Workflow
- [ ] 1. Inventory state in the feature (grep useState, stores, context, query keys)
- [ ] 2. Classify each value with the table above
- [ ] 3. Find duplicates: same data held by two owners
- [ ] 4. Find derived values that are stored; replace with computation
- [ ] 5. Move server data to the query cache; move shareable UI state to the URL
- [ ] 6. Shrink the global store to what is left
- [ ] 7. Verify refresh, back/forward, multi-tab and stale-data behavior
Inventory commands:
grep -rn "useState\|useReducer\|create(\|createSlice\|atom(\|defineStore" src | wc -l
grep -rn "setQueryData\|useQuery\|useSWR" src | head
grep -rln "useEffect" src | xargs grep -ln "set[A-Z][a-zA-Z]*(.*data" | head # effects copying fetched data into state
Server state with TanStack Query (v5)
// queries/projects.ts — one place for keys and fetchers
import { queryOptions } from "@tanstack/react-query";
export const projectKeys = {
all: ["projects"] as const,
list: (filters: ProjectFilters) => [...projectKeys.all, "list", filters] as const,
detail: (id: string) => [...projectKeys.all, "detail", id] as const,
};
export const projectQuery = (id: string) =>
queryOptions({
queryKey: projectKeys.detail(id),
queryFn: ({ signal }) => api.getProject(id, { signal }),
staleTime: 30_000,
});
// mutation with optimistic update and rollback
const qc = useQueryClient();
const rename = useMutation({
mutationFn: (v: { id: string; name: string }) => api.renameProject(v.id, v.name),
onMutate: async (v) => {
await qc.cancelQueries({ queryKey: projectKeys.detail(v.id) });
const prev = qc.getQueryData(projectKeys.detail(v.id));
qc.setQueryData(projectKeys.detail(v.id), (p) => (p ? { ...p, name: v.name } : p));
return { prev };
},
onError: (_e, v, ctx) => qc.setQueryData(projectKeys.detail(v.id), ctx?.prev),
onSettled: (_d, _e, v) => qc.invalidateQueries({ queryKey: projectKeys.all }),
});
Rules:
- Query keys include every input that changes the result (filters, page, locale, user/tenant). Missing inputs serve the wrong cached data.
- Never copy query data into
useStateor a store “to edit it”. Edit a form copy initialized from the data, or edit through mutations. - Set
staleTimedeliberately (default 0 means refetch on every mount/focus). Reference data: minutes to hours; collaborative data: seconds. - Invalidate by prefix after mutations (
projectKeys.all), not one key at a time. - With SSR frameworks, prefetch on the server and hydrate (
HydrationBoundary) rather than fetching twice.
URL state
Put in the URL: filters, sort, search query, pagination, selected tab, open detail-panel ID, date ranges. Keep out: secrets, large blobs, transient hover/focus.
// React + nuqs: typed, validated search params
import { useQueryState, parseAsInteger, parseAsStringLiteral } from "nuqs";
const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1));
const [sort, setSort] = useQueryState("sort", parseAsStringLiteral(["new", "top"] as const).withDefault("new"));
- Validate and default every param — users edit URLs.
- Use
replace(not push) for keystroke-level updates like search input, and debounce. - Feed URL values into query keys; the URL becomes the single source of truth and the cache follows.
Global client store (Zustand v5 example)
import { create } from "zustand";
import { persist } from "zustand/middleware";
type UIState = { sidebarOpen: boolean; density: "comfortable" | "compact"; toggleSidebar: () => void };
export const useUI = create<UIState>()(
persist(
(set) => ({
sidebarOpen: true,
density: "comfortable",
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
}),
{ name: "ui-prefs", version: 1 },
),
);
// Select narrowly so components re-render only when their slice changes
const open = useUI((s) => s.sidebarOpen);
- Always select slices;
useUI()with no selector re-renders on every change. For multi-field selections returning new objects, wrap withuseShallow. - Version persisted stores and write a
migratewhen the shape changes, or oldlocalStoragedata crashes new code. - In SSR apps, create the store per request (store factory + context provider); a module-level store is shared between users on the server.
Choosing a store:
| Need | Pick |
|---|---|
| Small-to-medium client state, minimal boilerplate | Zustand |
| Many independent fine-grained values, derived atoms | Jotai |
| Large team needing strict conventions, devtools time travel, middleware | Redux Toolkit (with RTK Query for server data) |
| Vue | Pinia (see vue-composition) |
| Svelte 5 | $state in .svelte.ts modules (see sveltekit-apps) |
Do not add a store library to solve prop drilling in one subtree; use composition or context first.
Duplicated-state smells and fixes
| Smell | Fix |
|---|---|
useEffect(() => setItems(data), [data]) | Use data directly; derive filtered/sorted views in render |
selectedUser object stored while list refetches | Store selectedUserId; look up from the list |
isLoading flag stored next to a query | Use the query’s status |
| Filter state in a store and in the URL | URL only; read it where needed |
total, count, isEmpty stored | Derive from the list |
| Same entity in two query keys edited separately | Invalidate the shared prefix, or normalize with setQueryData on both |
| Store reset bugs on logout | Clear the query cache (queryClient.clear()) and reset stores in one logout function |
Verification
- Refresh the page mid-flow: URL-owned state survives, ephemeral state resets as intended.
- Copy the URL into a new tab: same view.
- Back/forward moves through filter/tab changes that should be history entries, and not through every keystroke.
- Mutate data in one tab, focus the other: it refetches (or shows stale-by-design data
per
staleTime). - Log out and in as another user: no data from the previous user is visible anywhere.
- React Query Devtools / Redux DevTools show no entity lists duplicated between cache and store.
Output for a state audit
| Value | Current owner | Correct owner | Action |
|---|---|---|---|
| projects list | Zustand `projects` | Query `["projects","list",filters]` | Move; delete store slice |
| status filter | useState | URL `?status=` | Move to nuqs |
| visibleCount | useState via effect | Derived | Compute in render |
| sidebarOpen | Zustand | Zustand (persisted) | Keep | About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.