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.

  • namestate-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.

~/.claude/skills/state-management/SKILL.md

--- name: state-management description: Decides where each piece of frontend state belongs — component state, URL search params, server cache (TanStack Query, SWR, RTK Query), or… license: MIT ---

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.

QuestionOwnerTypical tools
Does it come from the server and can change without this user?Server cacheTanStack 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 rendercomputed, $derived, plain expressions
Is it a form’s in-progress input?Form stateNative form + FormData, React Hook Form, Superforms
Used by one component or a small subtree?Local component stateuseState/useReducer, ref, $state
Must it persist across sessions on this device?Storage-backed storeZustand persist, localStorage wrapper, IndexedDB
Shared by distant components, client-only, changes often?Global client storeZustand, Jotai, Redux Toolkit, Pinia, Svelte .svelte.ts state
Shared, rarely changes (theme, locale, user shape)?Context / provide-injectReact 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 useState or a store “to edit it”. Edit a form copy initialized from the data, or edit through mutations.
  • Set staleTime deliberately (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 with useShallow.
  • Version persisted stores and write a migrate when the shape changes, or old localStorage data 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:

NeedPick
Small-to-medium client state, minimal boilerplateZustand
Many independent fine-grained values, derived atomsJotai
Large team needing strict conventions, devtools time travel, middlewareRedux Toolkit (with RTK Query for server data)
VuePinia (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

SmellFix
useEffect(() => setItems(data), [data])Use data directly; derive filtered/sorted views in render
selectedUser object stored while list refetchesStore selectedUserId; look up from the list
isLoading flag stored next to a queryUse the query’s status
Filter state in a store and in the URLURL only; read it where needed
total, count, isEmpty storedDerive from the list
Same entity in two query keys edited separatelyInvalidate the shared prefix, or normalize with setQueryData on both
Store reset bugs on logoutClear the query cache (queryClient.clear()) and reset stores in one logout function

Verification

  1. Refresh the page mid-flow: URL-owned state survives, ephemeral state resets as intended.
  2. Copy the URL into a new tab: same view.
  3. Back/forward moves through filter/tab changes that should be history entries, and not through every keystroke.
  4. Mutate data in one tab, focus the other: it refetches (or shows stale-by-design data per staleTime).
  5. Log out and in as another user: no data from the previous user is visible anywhere.
  6. 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.

esc