Claude Fable 5.1 & GPT-6 Astra packages are live

State Management

Free · MIT

Choosing where state lives — server cache versus client state, URL as state, and picking a library only when local state genuinely cannot serve.

216 lines9.4 KB Mistral Frontend
Target models
Mistral Medium 3.5Mistral Large 3Mistral Small 4Mistral FamilyFuture Mistral Models
Name
state-management
Category
Frontend
Description
Choosing where state lives — server cache versus client state, URL as state, and picking a library only when local state genuinely cannot serve.
License
MIT
Author
Agent.md maintainers
Last verified
2026-08-23
Reviewed by
unreviewed

#How to apply this file

Each section opens with one imperative line; apply every rule in the section it introduces. Do not summarise or skip a section.


#Purpose

Rules for deciding where application state lives. Most "state management problems" are really one mistake: treating server data as client state.

Server data is a cache of something you do not own. It goes stale, it needs revalidation, it can fail and retry. Client state is yours and is always correct. Managing the first with the tools for the second produces most of the complexity people attribute to React.


#Classify first, then choose

[INST] Apply every rule in this section: Classify first, then choose. [/INST]

KindExampleWhere it belongs
Server dataOrders, profile, search resultsA server-cache library
URL stateFilters, page, tab, selected idThe URL
Form stateField values while editingThe form, locally
Local UI stateDropdown open, hoveruseState in the component
Shared UI stateTheme, sidebar, toastsContext, or a small store
Session identityCurrent user, permissionsServer-provided; cached, never authoritative

Walk down this list before reaching for a library. Most applications need no global state library once server data moves to a cache and filters move to the URL.


#Server data belongs in a server cache

[INST] Apply every rule in this section: Server data belongs in a server cache. [/INST]

tsx
// Manual: no deduplication, no revalidation, no retry, no cache — reimplemented
// slightly differently in every component that needs orders.
const [orders, setOrders] = useState([]);
useEffect(() => { fetch("/api/orders").then(r => r.json()).then(setOrders); }, []);

// A cache: deduplicated, revalidated, retried, shared across components
const { data, error, isPending } = useQuery({
  queryKey: ["orders", { status }],
  queryFn: () => api.orders.list({ status }),
  staleTime: 30_000,
});

@tanstack/react-query, SWR, Apollo, RTK Query, or a framework loader (Next.js server components, Remix loaders) all solve this. What they give you and hand-rolled effects do not:

  • Request deduplication across components mounting simultaneously
  • Background revalidation and stale-while-revalidate
  • Retry with backoff, and error and loading states as data
  • Cache invalidation by key after a mutation
  • Race-condition handling for out-of-order responses

Never copy fetched data into a global store. You then own invalidation, and the store and the server diverge silently.

The query key is the cache identity: include every parameter that changes the result, or two different filters will share one cache entry.


#The URL is state

[INST] Apply every rule in this section: The URL is state. [/INST]

Filters, sort, pagination, the open tab, the selected record — all belong in the URL.

tsx
const [params, setParams] = useSearchParams();
const status = params.get("status") ?? "all";

If it is not in the URL, the user cannot bookmark it, share it, or use the back button — and a refresh loses it. This is a user-facing bug that appears as "the link I sent shows something different".

Keep it clean: omit defaults rather than serialising ?status=all&page=1, and never put a secret or personal data in a query string, where it lands in logs and Referer headers. → API/api-security


#Client state: local first

[INST] Apply every rule in this section: Client state: local first. [/INST]

Start with useState in the component that owns it. Lift only when a second component genuinely needs the same value, and only to the lowest common parent.

Context is not a state manager. Every consumer re-renders when the value changes, so a single context holding everything re-renders the whole tree on any change.

  • Split contexts by update frequency: a rarely-changing ThemeContext and a frequently-changing one should not be the same provider.
  • Memoise the context value, or every provider render invalidates all consumers.
  • Context is right for dependency injection (theme, locale, the current user) and wrong for high-frequency state.
LibraryModelReach for it when
@tanstack/react-queryServer cacheAny remote data — this is the default
zustandSingle store, selector subscriptionsShared client state, minimal boilerplate
jotaiAtomic, bottom-upMany independent pieces of fine-grained state
@reduxjs/toolkitSingle store, reducers, devtoolsLarge apps needing traceable transitions
xstateExplicit state machinesMulti-step flows with complex legal transitions
React useReducer + contextBuilt inA handful of values, low update frequency

When a store is genuinely warranted — shared, frequently updated client state across distant components — pick a small one with selector-based subscriptions (zustand, jotai, valtio). Redux Toolkit remains reasonable for large applications that need strict traceability of every transition.


#Keep state modelling honest

[INST] Apply every rule in this section: Keep state modelling honest. [/INST]

ts
// Permits { isLoading: true, error: Error, data: Data } — three impossible states
{ isLoading: boolean; error: Error | null; data: Data | null }

// One value; illegal combinations cannot be represented
type State = { status: "idle" } | { status: "loading" }
           | { status: "error"; error: Error } | { status: "success"; data: Data };
  • Never store derived data. Compute it. A totalCents alongside items will diverge.
  • Never duplicate the same value in two stores.
  • Normalise collections by id rather than nesting the same entity in several places, so one update does not need to find every copy.
  • Persisted state (localStorage) must be versioned and migrated, or a returning user with an old shape crashes the application. Never persist tokens or personal data there. → Frontend/hooks

Optimistic updates need a rollback path. Applying the change and only then discovering the mutation failed, with no way back, is worse than a spinner.


#Anti-patterns

[INST] Apply every rule in this section: Anti-patterns. [/INST]

Anti-patternWhy it failsFix
Server data in a global storeYou own invalidation; it divergesServer-cache library
useEffect + useState fetchingNo dedup, retry, cache or race handlingQuery library or loader
Query key missing a parameterTwo filters share one cache entryInclude everything that affects the result
Filters and pagination in local stateNot shareable, lost on refresh, back button brokenPut them in the URL
Secrets in query stringsLogged and leaked via RefererNever
Reaching for Redux by defaultLarge surface for a problem you may not haveLocal state first
One context for everythingWhole tree re-renders on any changeSplit by update frequency
Unmemoised context valueEvery provider render invalidates consumersMemoise
Context for high-frequency stateRe-render stormsA store with selectors
Boolean flags for a state machineImpossible states become reachableDiscriminated union
Derived data storedTwo sources of truth divergeCompute it
Same value in two storesGuaranteed to divergeOne owner
Deeply nested entity copiesOne update must find every copyNormalise by id
Unversioned persisted stateAn old shape crashes returning usersVersion and migrate
Tokens in localStorageXSS becomes account takeoverHttpOnly cookie
Optimistic update with no rollbackFailed mutations leave wrong UIRoll back on error

#Checklist

  • Verify: Every piece of state is classified before choosing where it lives
  • Verify: Server data lives in a server-cache library or framework loader
  • Verify: Query keys include every parameter that affects the result
  • Verify: Fetched data is not copied into a global store
  • Verify: Filters, sort, pagination and selection live in the URL
  • Verify: Default values are omitted from the URL; no secrets appear in it
  • Verify: Client state starts local and is lifted only when genuinely shared
  • Verify: Contexts are split by update frequency and their values memoised
  • Verify: High-frequency shared state uses a store with selector subscriptions
  • Verify: A global state library is introduced only after local state proved insufficient
  • Verify: Related state is modelled as a discriminated union
  • Verify: No derived data is stored; no value is duplicated across stores
  • Verify: Collections are normalised by id
  • Verify: Persisted state is versioned with a migration path
  • Verify: No tokens or personal data are persisted client-side
  • Verify: Optimistic updates roll back on failure