The state management decision record: why the client state model you chose determines your derived state complexity and your server cache synchronization overhead

State management is decided in the first sprint when a component needs data from a sibling — and never documented as a deliberate architecture choice with client state versus server state classified, derived state memoization policy established, or optimistic update strategy defined. The model determines whether server data in the global store goes stale silently while the backend moves on, whether every Context consumer re-renders when the cart quantity increments, whether adding a second engineer doubles the number of stale-data incidents, and whether an optimistic mutation requires fifty lines of compensating transaction logic or three library-provided callbacks. These properties are set when the founding engineer installs a state management library and writes the first reducer or store slice — and are invisible in any subsequent component or API reference.

An 18-person B2B analytics SaaS chose Redux in the founding sprint. The founding engineer had come from a large enterprise team where Redux was the architectural standard: it enforced predictable data flow, the Redux DevTools time-travel debugger caught race conditions during development, and the unidirectional action-reducer model made the codebase legible to engineers joining the team at different stages. The first six months validated the choice. The team built a complex dashboard with 12 chart types, each reading different slices of state, and Redux's normalized store made cross-component data sharing clean and auditable.

The problem emerged in the second year. The application's primary function was displaying analytics data fetched from an API — workspace metrics, user cohorts, funnel steps, retention curves. Each dashboard panel fetched a different endpoint. The founding engineer had modeled this naturally: a fetchWorkspaceMetrics async thunk dispatched a loading action, awaited the API response, and dispatched a success action that stored the metrics in the Redux store under a key for the current workspace and time range. Every panel had a corresponding thunk. The Redux store became the application's data layer — the single source of truth for all fetched data, as Redux documentation described it.

After fourteen months of active development, the store had 47 reducers. Thirty-one of those 47 reducers existed to manage the loading, error, and success states of API calls — three state fields per endpoint, multiplied across 10+ API resource types. The remaining 16 reducers managed actual client-owned UI state: selected time range, active dashboard layout, comparison period, open filter panel. The engineers on the team had begun writing a custom "data freshness" system: when a user navigated between dashboards, some panels were showing metrics from a previous API call that was now several minutes old. The team tracked which data was stale by storing a fetchedAt timestamp in each reducer's state, then checking it on component mount and dispatching a refetch action if the data was older than 5 minutes. The system required a staleDataMiddleware that intercepted route changes, read the fetchedAt timestamps for all reducers relevant to the incoming route, and dispatched INVALIDATE_* actions for those that had crossed the staleness threshold.

Two engineers spent a week building and debugging the staleness middleware. A third engineer, reviewing the PR, noted: "This is basically TanStack Query's staleTime and automatic background refetch, but implemented as Redux middleware." The observation was accurate. The 31 request-lifecycle reducers, the fetchedAt timestamp tracking, the staleness middleware, the INVALIDATE_* action types, and the manual refetch dispatches were collectively reimplementing the server state lifecycle that TanStack Query manages as a first-class concern. The week spent building the custom staleness system was the week the team first understood that they had stored server state — data owned by the backend, with a known staleness model, subject to background updates — in a general-purpose client state store that had no built-in model for server ownership, staleness, or background refetch.


A 9-person startup building a B2C e-commerce platform chose local useState for their initial state management. The founding engineer had worked with older frameworks where global state was a source of hidden coupling and debugging difficulty, and wanted to keep state co-located with the components that needed it. For the first three months, the approach worked: each page component managed its own data, API calls lived in useEffect hooks, and state was local to the component that owned the interaction.

The first friction appeared when the cart was built. The cart state — the list of items a user had added — lived in a useState call in the CartPage component. The header needed to display the cart item count. The checkout confirmation page needed the cart total for display after payment. The founding engineer moved the cart state up to the App component and passed it down as props to Header, CartPage, and CheckoutPage. This worked.

The prop chain grew. The team added a promotions banner that needed the cart quantity to decide which promotion to display. The banner was a child of Layout, which was a child of App — two levels above the banner in the tree but three levels above CartPage. Cart props were already at App; the engineer added them to Layout's props and to the banner's props. The chain was five components deep for the banner. When the team added user preference state (dark mode, notification preferences, language setting), the engineer created a React Context for it rather than extending the prop chain. When they needed to share notification state across the dashboard and the mobile navigation, they created a second Context.

The performance problem arrived with the third Context. Each Context Provider re-rendered all its consumers whenever the context value changed. The CartContext value was an object containing the cart items array and the total. When a user added an item to the cart, the context value changed, and every component subscribed to CartContext re-rendered — including the header (correct), the checkout page (correct), the promotions banner (correct), and the notification badge (incorrect — the notification badge had been placed inside CartContext.Provider for layout reasons and subscribed to the context to check a different value, but re-rendered on every cart change anyway). The engineers spent two days tracing re-render cascades with the React DevTools profiler. The root cause was that multiple contexts had been added incrementally to solve specific prop-drilling problems, without a documented policy for what state should be global, what should be Context-shared, and what state category each piece of state belonged to.

Fixing the performance problem required splitting each Context into separate data and dispatch contexts (so consumers needing only the dispatch function did not re-render when data changed), memoizing context values with useMemo, and wrapping pure display components with React.memo. The work was correct but reactive — it addressed the symptom (excessive re-renders) rather than the root cause (no deliberate state classification at the time the first Context was created). A documented policy distinguishing global client state from local component state from server-cached data would have identified the cart as a candidate for a lightweight global store and the notifications as a candidate for a server state cache, both of which have libraries specifically designed to avoid the re-render and staleness problems the team encountered.

The four structural properties that are decided in the founding sprint

Both incidents — the Redux staleness middleware reimplementing TanStack Query's server state model, and the Context re-render cascade from incremental global state accumulation — were caused by structural properties established when the state management approach was selected. These properties are not visible in the component tree, the API reference, or the library documentation. They are visible only in the state classification: whether each piece of state is server-owned or client-owned, whether global state lives in a store designed for that category, and whether derived state has a documented computation and memoization policy. The founding session that answers "how should we manage state?" establishes all four properties — and closes before any of them are documented.

1. Client state versus server state: the primary classification

The most consequential state management decision is not which library to use. It is which category each piece of state belongs to — because the category determines which library is correct for it.

Server state is data owned by the backend. A list of orders, a user's profile, a workspace's members, a product's inventory count. It exists on the server, is fetched asynchronously across a network boundary, can become stale when the backend updates it, and may be changed by other users or background processes between the client's last fetch and the current render. The client does not own server state — it holds a cached copy. That copy has a known staleness model: data fetched 30 seconds ago is likely fresh; data fetched 10 minutes ago may be stale; data fetched in a previous browser session is certainly stale. The lifecycle of server state — loading, error, stale, fresh, background-refetching — is a well-defined problem with a well-defined solution.

Client state is data owned by the browser. A modal's open/closed condition, a multi-step wizard's current step, a filter panel's expanded state, a form field's draft value before submission, a selected tab index. Client state is created by user interaction, lives only in the current session, and is never true on the server. There is no API endpoint that returns whether the settings drawer is currently open. When the browser refreshes, client state resets; when the user opens a second tab, client state is independent between tabs; when the user is offline, client state is fully available. Client state has none of server state's lifecycle properties: it does not go stale, it does not need background refetch, and it does not need invalidation when a mutation succeeds.

The founding state management decision conflates these two categories when the first API call is made and its response is stored in a global store alongside UI state. The 31 request-lifecycle reducers in the first incident were the cost of storing server state in a general-purpose client state store and then re-implementing the server state lifecycle properties that the store did not provide. A state management ADR that documents which state categories exist in the application — server state, global client state, local component state — before selecting a library for each category is the document that prevents that reimplementation cost.

2. Normalized versus denormalized store and entity deduplication

When a user object appears in multiple places in the UI simultaneously — in the page header, in a comment thread, in a team member list — a normalized store stores the user once, keyed by its ID, and all three rendering locations read from the same stored entity. An update to the user's name dispatches once and all three locations reflect the change on the next render. A denormalized store duplicates the user object in each location's state slice: the header has a copy, the comment thread has a copy, the team member list has a copy. An update to one copy leaves the others unchanged — three renders, three potentially inconsistent values, three bugs waiting for the user to notice the inconsistency.

Normalization is the correct model for relational data — entities with IDs that appear in multiple contexts. Redux's createEntityAdapter in Redux Toolkit provides a normalized store slice for a given entity type, with selectors for selectAll, selectById, and selectIds that read from the normalized table. A usersAdapter.upsertMany call on a list API response stores all users in the normalized table regardless of which feature fetched them; a usersAdapter.upsertOne call on a profile update propagates to all rendering locations immediately.

TanStack Query provides a different normalization model: its server state cache is keyed by query key (an array that identifies the specific query — ['user', 42] for user 42's data). A mutation that updates user 42 can call queryClient.invalidateQueries({ queryKey: ['user', 42] }), which marks all cached entries for that key as stale and triggers background refetch for any currently active query subscribed to that key. The normalization is at the cache key level rather than at the entity level: two components that both query ['user', 42] share the same cached data and receive the same refetch trigger. Two components that query ['users'] (the list) and ['user', 42] (the individual profile) do not automatically share cache entries — the mutation must invalidate both keys. This key-based invalidation model requires the cache key schema to be designed deliberately: which keys represent the same logical entity, and which mutations should invalidate which keys. The API schema design decision record documents the response shapes and entity identifiers; the state management ADR documents the cache key schema that maps those entities to TanStack Query cache entries and the invalidation policy that keeps related cache keys consistent after mutations.

The decision between entity normalization (Redux Toolkit adapter) and key-based cache invalidation (TanStack Query) is the structural normalization decision — and it is established when the first multi-entity view is built without a documented policy. The team in the first incident used Redux's normalized store for UI entities but did not use entity normalization for API response data, storing full response objects per endpoint rather than normalizing the embedded entities. The result was stale data in one slice when a mutation updated the same entity in a different slice — the same entity duplication problem at the API response level that normalization at the entity level would have prevented.

3. Derived state, selector memoization, and computation cost

Derived state is any value computed from raw state: a filtered list (all tasks assigned to the current user), an aggregated total (the cart's subtotal from line item quantities and prices), a formatted display value (a date formatted for the user's locale from a stored ISO timestamp), a boolean derived from a collection (whether the current user has any overdue tasks). Derived state can always be computed from raw state on demand; the question is whether computing it on every state change — including state changes that do not affect the inputs to the computation — is acceptable.

In Redux, derived state is computed by selectors — functions that receive the Redux state tree and return a computed value. A selector that filters a list of 2,000 tasks to those assigned to the current user runs on every Redux state dispatch, including dispatches that change unrelated state (closing a modal, switching a tab, updating a search field). Without memoization, the filter runs 200 times per minute on a moderately active UI. Reselect's createSelector memoizes the computation: the selector function is only rerun when its input selectors return different values. A selectTasksForCurrentUser selector that inputs selectAllTasks and selectCurrentUserId only reruns when the task list or current user ID changes — not when a modal is closed.

The memoization requirement creates a second structural problem: selector composition. A component that needs "all overdue tasks assigned to the current user grouped by project" requires composing three selectors: filter by assignee, filter by due date, group by project. Each composition layer must be memoized independently, or the final selector reruns when any intermediate value changes — even if the change does not affect the final output. A memoization policy that requires createSelector for any derived value that applies filtering, sorting, or transformation to a collection prevents unmemoized selector proliferation; a policy that leaves memoization as an engineer's individual judgment produces a selector layer where some are memoized and some are not, making re-render performance unpredictable and difficult to diagnose. The performance optimization decision record covers render profiling methodology and the tooling for measuring selector computation cost; the state management ADR documents the memoization policy that prevents the cost from accumulating in the first place.

In TanStack Query, derived server state is handled by the select option: a transformation applied to the cached query data before the component receives it. The select function is memoized by structural equality — it only reruns when the underlying cache data changes. A query that fetches 2,000 tasks but transforms to 40 tasks assigned to the current user will not re-render the component when an unrelated cache entry changes. The select option shifts derived state computation from the component (which would rerun on every render) to the cache layer (which only reruns when the source data changes). The equivalent of the reselect memoization pattern is built into the data access API.

4. Optimistic updates, rollback, and the mutation model

When a user marks a task as complete, the best user experience reflects the change immediately in the UI — the task moves to the "completed" column, the count updates, the checkmark fills — and rolls back silently if the server returns an error. This is an optimistic update: the UI optimistically applies the expected successful outcome before the server confirms it, and compensates by reverting if the server rejects it.

In Redux, implementing an optimistic update for a single mutation requires: dispatching an optimistic action that updates the store before the API call; generating a unique ID for the in-flight optimistic state so the rollback action knows which state to revert; storing the pre-optimistic snapshot or the optimistic ID in the action payload; awaiting the API call in a thunk or saga; on error, dispatching a rollback action with the ID to revert the optimistic state; on success, optionally dispatching a confirmation action that normalizes the server's response version of the entity over the optimistic version. This is a well-understood pattern — it is documented in the Redux Toolkit documentation under "optimistic updates" — and it requires 40–60 lines of thunk code per mutation type. A codebase with 20 mutation types and an inconsistent application of the optimistic pattern (some mutations are optimistic, some are not, some have rollback and some show an error toast on failure without reverting) has accumulated 20 independent mutation implementations with no documented policy for which mutations should be optimistic and how rollback is handled.

TanStack Query's mutation API provides onMutate, onError, and onSettled callbacks for each mutation. In onMutate, the engineer cancels any in-flight queries for the affected cache key, reads the current cache snapshot, and applies the optimistic update to the cache directly via queryClient.setQueryData. In onError, the engineer restores the snapshot saved in onMutate — one line, using the context value returned from onMutate. In onSettled, the engineer invalidates the affected query keys to trigger a background refetch that resolves any discrepancy between the optimistic state and the server's authoritative response. The optimistic update pattern is 15–20 lines per mutation, uses the same API across all mutations, and the rollback is automatic from the saved snapshot rather than a separately dispatched action. The error handling strategy decision record documents the error UI pattern — whether a failed optimistic mutation shows an error toast, a banner, or an inline error — but the rollback mechanism itself belongs in the state management ADR as the optimistic update policy.

State management options and their structural properties

Local useState and useReducer: Component-scoped state that is created when the component mounts and destroyed when it unmounts. Correct for all UI state that is not shared across unrelated component trees: form field values before submission, accordion open/closed state, a dropdown's current value, a pagination component's current page, a modal's open/closed state when only one component opens that modal. Zero infrastructure, co-located with the component that owns the interaction, automatically garbage-collected when the component unmounts. The rule for keeping state local: if removing the component from the tree should also remove the state, the state should be local. If the state should survive navigation, survive the component unmounting, or be readable by a component that is not a descendant of the component that creates it, the state should be promoted to a higher level.

React Context API: Suitable for slowly-changing global values that must be readable by any component in the tree without explicit prop passing — authentication status, color theme, active locale, feature flag set. Not suitable for high-frequency state changes because every component subscribed to a Context re-renders when the context value changes, regardless of whether the subscribed component reads the specific part of the value that changed. The re-render performance degrades proportionally to the number of Context consumers multiplied by the frequency of context value changes. A cart context whose value is an object with items and total fields causes every cart consumer to re-render when either field changes — even consumers that display only the item count and do not use the total. Splitting into separate contexts (one for data, one for dispatch) reduces the re-render surface but requires more Context setup. A Context used for state that changes more than once per second is a performance liability that a purpose-built state library would avoid. The frontend framework decision record documents whether the project uses Next.js App Router with React Server Components, which changes the state management model significantly — server components cannot use useState or Context, and the boundary between server components and client components must be documented as part of the state classification. The authentication strategy decision record covers the token format and session lifecycle; the state management ADR documents whether the authenticated user object is stored in Context (appropriate for an auth-gated SPA where the user is always present and rarely changes) or in a server state cache (appropriate for applications that re-verify session state on navigation or after extended inactivity).

Zustand: A minimal global state library (~1KB) built on a store that is a plain JavaScript function returning state and actions. No boilerplate, no reducers, no action types — state is updated by calling a setter function that receives the current state. Suitable for global client UI state where Context's re-render overhead is a concern but Redux's structure is unnecessary: the current user's selected workspace ID, a multi-step onboarding wizard's current step and completions, a notification tray's open/closed state, a global search overlay's visibility. Zustand stores use subscriptions with an optional equality function — a component subscribing to useStore(state => state.workspaceId) only re-renders when workspaceId changes, not when other store fields change. This solves the Context re-render problem without requiring the Redux action-reducer pattern. Zustand works alongside TanStack Query: Zustand owns global client state; TanStack Query owns server state; local useState owns component-scoped UI state. The three-library combination — local state, Zustand, TanStack Query — covers most application state categories without Redux's structural overhead. The feature flag decision record documents the flag evaluation library and the flag update cadence; state management determines whether flag values are held in a Zustand store (appropriate if flags are fetched once at session start and rarely change) or in a TanStack Query cache (appropriate if flags are re-fetched at defined intervals or after specific user actions).

Redux Toolkit: The current standard for Redux, replacing hand-written reducers and action creators with createSlice, createAsyncThunk, and createEntityAdapter. Justified for complex multi-entity client state where normalization, cross-entity relationships, and unidirectional data flow reduce race conditions and coordination costs between engineers. Provides the Redux DevTools time-travel debugger, which is uniquely valuable for debugging complex state transition sequences that are difficult to reproduce in testing. The createEntityAdapter normalized store is the best approach for relational entities that appear in multiple views simultaneously. The remaining cost is structural: all state reads must go through selectors, all state writes must go through dispatched actions, and all async operations must be expressed as thunks or sagas. For applications where more than half of the global state is server-owned data, this structure adds overhead that TanStack Query would eliminate. The multi-tenancy decision record documents the tenant isolation model; state management determines whether the current tenant context (tenant ID, tenant settings, tenant feature set) is stored in Redux (appropriate if tenant switching requires a full store reset) or in Zustand (appropriate if tenant context is a small set of UI values that can be updated in place without resetting other global state).

Jotai: An atomic state library where state is decomposed into atoms — the smallest units of state — and derived atoms that depend on other atoms and recompute only when their dependencies change. A component subscribes to one or more atoms and re-renders only when those specific atoms change, providing fine-grained reactivity without the selector memoization overhead of Redux. Derived atoms implement the selector memoization policy automatically: a derived atom that filters a list of tasks by the current user ID reruns only when the task list atom or the current user atom changes. Suitable for applications with complex derived state graphs where Redux's selector memoization policy would require extensive createSelector composition. The atomic model has a different debugging ergonomic than Redux DevTools — the state is distributed across atoms rather than consolidated in a single store tree, which makes global state inspection less straightforward but per-atom change tracking more granular.

TanStack Query: A dedicated server state library. Manages the full server state lifecycle: loading, error, stale, and fresh phases; automatic background refetch on browser tab focus and network reconnection; stale-while-revalidate (returning stale data immediately while a background refetch runs, so the UI is never blocked on a spinner for data that was recently fetched); cache invalidation by query key on mutation success; optimistic updates with automatic rollback; pagination and infinite scroll patterns; prefetching for predictable navigation. The query cache is keyed by arrays — ['users'], ['user', 42], ['user', 42, 'orders'] — with hierarchical invalidation: invalidating ['user', 42] marks all cache entries whose key starts with ['user', 42] as stale. The stale time and garbage collection time are configurable per query key, allowing frequently updated data (real-time dashboard metrics) to have a zero stale time while slowly changing reference data (country list, plan tiers) has a multi-hour stale time. TanStack Query eliminates the server state category from Redux or Zustand entirely: no loading reducers, no error reducers, no manual refetch dispatches, no custom staleness middleware. The real-time architecture decision record documents the WebSocket or SSE implementation for live data; the state management ADR documents how real-time events integrate with the TanStack Query cache — whether a WebSocket message calls queryClient.invalidateQueries (triggering a refetch) or queryClient.setQueryData (directly updating the cache with the event payload), and which approach is correct per data type based on whether the event payload is a complete authoritative update or a delta that requires reconciliation with the cached state.

MobX: An observable-based state library where state objects are wrapped in observable, and React components that access observables automatically subscribe to them. State updates are direct assignments — store.user.name = 'Alice' — without dispatching actions. The MobX runtime tracks which observables each component accessed during its last render and re-renders only those components when the accessed observables change. Computed values declared with computed memoize automatically, rerunning only when their observable dependencies change. MobX has the lowest boilerplate of any global state library and the highest "magic" — the automatic subscription tracking and reaction system makes behavior difficult to understand without MobX DevTools and can produce unexpected reaction cascades when observable objects are mutated in unexpected orders. MobX remains a strong choice for applications with complex, richly interrelated domain models where the observable model maps naturally to the domain objects and where the team is committed to using MobX DevTools as the primary debugging interface. It is not a common first choice for new projects started after 2022 because the React ecosystem has converged toward explicit subscription models that are easier to understand without specialized tooling.

AI chat sessions where state management decisions are made

State management decisions are made in four types of AI chat sessions, each of which establishes structural properties that are not visible in the component or the store:

The "how do I share state between these two components?" session. "I have a cart component and a header component and I need the header to show the item count from the cart. How do I share that state?" This session covers lifting state up to a common parent, passes a prop chain down to the header, and closes when the engineer implements the prop. It does not cover whether the cart is client state or server state, whether this is the first of many shared state requirements or an isolated case, or what mechanism is appropriate for sharing state that will eventually be needed by 8 components across different subtrees. The recommendation in this session — lift state to a common parent — is correct for the immediate requirement and establishes a prop-chain pattern that scales poorly to the 6th shared state requirement. The WhyChose extractor recovers this session from the AI chat export alongside the 5 follow-up sessions where the engineer asks "how do I avoid this prop drilling?" — the full sequence shows the pattern that led to the Context accumulation and the eventual performance problem.

The "how do I fetch and store API data in React?" session. "I'm making an API call in useEffect and I want to store the response so multiple components can access it. Should I use Redux?" This session covers the options available — local state with props, Context, Redux — and typically recommends Redux for the "store for multiple components" requirement. It does not cover whether the API response is server state (owned by the backend, subject to staleness) or client state (owned by the client session), whether TanStack Query is the more appropriate tool for server state, or what the staleness model should be for the fetched data. The Redux recommendation in this session is the founding decision that creates 31 request-lifecycle reducers over the following 14 months. A session that explicitly identifies the data as server state and evaluates TanStack Query as the server state tool would redirect the architectural pattern before the first reducer is written. The WhyChose extractor surfaces this session from the founding sprint as the state management origin point — the session that answered "what tool manages our data?" with "Redux" rather than "what type of state is this, and which tool is designed for that type?"

The "my component is re-rendering too much" session. "The React DevTools profiler shows that my CartContext consumers are re-rendering on every state change even though they only need the item count. How do I fix this?" This session covers splitting contexts, memoizing context values, wrapping components with React.memo, and using useCallback for stable function references. It produces a fix for the specific performance problem and closes when the profiler shows fewer unnecessary renders. It does not produce a documented policy about which state should be in Context versus a purpose-built store, why the Context re-render problem emerged from a state classification decision rather than from a missing memo call, or what the criterion is for deciding when Context is the wrong tool. The performance optimization decision record covers the profiling methodology and the memoization tools; the state management ADR documents the classification policy that prevents the re-render problem by routing high-frequency state to stores that are designed for it instead of to a Context whose re-render behavior is proportional to consumer count multiplied by change frequency.

The "how do I do an optimistic update for this mutation?" session. "When the user clicks the 'mark complete' button I want the task to move to the completed column immediately without waiting for the API response. If the API fails I need to undo it. How do I implement this in Redux?" This session produces a thunk with optimistic dispatch, a rollback action type, and error handling that dispatches the rollback on failure. The implementation is correct and covers the specific mutation. It does not produce a documented optimistic update policy that covers all mutations in the application, a standard rollback mechanism that is consistent across mutation types, or an evaluation of whether TanStack Query's mutation API would reduce the per-mutation implementation cost from 50 lines to 15. When the next mutation needs an optimistic update, a different engineer implements it differently — one using an OPTIMISTIC_UPDATE action type pattern, another using a timestamp-based pending state, another skipping the optimistic update entirely and showing a spinner instead. The WhyChose extractor recovers all four sessions from the AI chat history as a pattern of per-mutation decisions that collectively describe the optimistic update policy that was never explicitly documented as one.

Writing the state management ADR

The state management ADR has five sections. Each section addresses one structural property that is established when the state management approach is selected and difficult to change retroactively — because the entire codebase is coupled to the state category model, the store structure, the selector memoization pattern, and the mutation implementation approach that the founding session chose.

Section 1: Client state versus server state classification. Before selecting any library, list the categories of state in the application and classify each as server state, global client state, or local component state. Server state examples: the authenticated user's profile (fetched from /api/me, subject to staleness, owned by the backend); the list of projects in the current workspace; a task's details when opened in a detail panel. Global client state examples: the current user's selected workspace ID (drives all API requests, must survive route changes, not stored on the server per session); the theme preference (if stored locally rather than server-side); the notification tray's open/closed state (needed by both the tray component and a badge in the navigation). Local component state examples: whether a specific accordion section is expanded; the current value of a search field before submission; which table row is hovered. The classification must be explicit in the ADR because it is the decision that determines which library is correct for each category. A team that classifies server state correctly before selecting a library will not need 31 request-lifecycle reducers. A team that classifies global client state correctly will not create a Context whose consumers re-render on every cart item addition. The GraphQL vs REST decision record documents the API query model; for GraphQL applications with Apollo Client, the Apollo Client normalized cache partially replaces a dedicated server state library — the ADR must document whether Apollo Client's cache is sufficient for the server state category or whether TanStack Query is also used for REST endpoints alongside Apollo for GraphQL.

Section 2: Server state library selection and cache policy. Document the server state library selected (TanStack Query, SWR, Apollo Client's cache for GraphQL, or custom Redux request lifecycle reducers with explicit rationale for why the purpose-built library is not appropriate). For TanStack Query or SWR, document the cache key schema: the array format for query keys, the hierarchy that supports wildcard invalidation (['projects'] as the root key whose invalidation triggers all project-related query refetches), and the naming convention for parameterized keys (['project', projectId], ['project', projectId, 'tasks']). Document the stale time and garbage collection time per resource category: frequently updated data (real-time metrics, notification counts) with staleTime: 0 and a short GC time; slowly changing reference data (pricing plans, country lists) with staleTime: 3600000 (one hour) and a long GC time. Document the default refetch behavior — whether data refetches on window focus and network reconnect by default, and which resource types opt out of automatic refetch because they are too expensive to refetch on every focus event. The caching strategy decision record documents the HTTP cache policy and CDN configuration for REST endpoints; the state management ADR documents the client-side server state cache policy that operates below the HTTP cache — the TanStack Query stale time and GC time are the client-side cache TTL parameters, and they must be tuned per resource type as deliberately as CDN cache headers.

Section 3: Global client state library selection. Document the library selected for global client UI state — Zustand, Redux Toolkit, Jotai, MobX, or Context — with the explicit rationale based on the state classification and the team size. For Zustand: document the store structure (one store per feature domain or one unified store), the slice naming convention, and the selector pattern for subscribing to specific fields without re-rendering on changes to other fields. For Redux Toolkit: document the slice structure, the entity adapter usage for relational entities, and the policy for which state belongs in a Redux slice versus a TanStack Query cache (global client state in Redux, server state in TanStack Query, with a documented boundary that engineers reference when adding new state). For Jotai: document the atom naming convention, the policy for when a primitive atom versus a derived atom is appropriate, and the async atom pattern for atoms that depend on server data without duplicating TanStack Query's server state management. Document the global vs local promotion criterion explicitly: the rule for when a piece of state that is currently local should be promoted to the global store. A common criterion: if state needs to be readable by a component that is not a descendant of the component that creates it, or if state must survive the component that creates it unmounting (route navigation), it belongs in the global store. If neither condition is true, it stays local.

Section 4: Derived state and selector memoization policy. Document the memoization policy for derived state: which library is used for memoization (reselect for Redux, computed atoms for Jotai, equalityFn subscriptions for Zustand, the TanStack Query select option for server state transforms), and the rule for when a derived computation must be memoized. A reasonable rule: any derived computation that applies filtering, sorting, grouping, or aggregation to a collection of more than 10 items must be memoized. Any derived computation that formats or transforms values for display (dates, currency, percentages) must be memoized if the component that consumes it renders in a list of more than 20 items. Document the input selector structure for Redux's reselect selectors: every derived selector is composed from input selectors that extract raw values, and the derived selector applies the computation. A selector that directly reads from the state object without input selectors bypasses memoization if the state object reference changes on every dispatch. The test strategy decision record covers the testing approach for selectors and computed state; selectors with documented memoization expectations are easier to test because the input/output boundary is explicit — input selectors return raw values, derived selector applies a pure function, and the memoization behavior can be asserted by calling the selector twice with the same reference and verifying the output reference is the same.

Section 5: Optimistic update and rollback strategy. Document the optimistic update policy: which mutation types use optimistic updates (immediate UI reflection before server confirmation), which types use pessimistic updates (spinner or disabled button until server confirms), and the default for mutations not explicitly classified. Document the rollback mechanism per mutation category: for TanStack Query mutations, the onMutate / onError / onSettled pattern with the cache snapshot saved in onMutate's context and restored in onError; for Redux, the action pair pattern (optimistic action dispatched before the API call, rollback action dispatched on error with the ID of the optimistic state to revert). Document the user-visible rollback UX: when an optimistic update is rolled back, how does the user know? An error toast, an inline error message on the affected item, or a silent revert. The UX decision is in the error handling strategy decision record, but the state management ADR must document the rollback mechanism that the error handling pattern operates on. Document the cache invalidation policy on mutation success: which query keys are invalidated when each mutation type succeeds, and the timing (invalidate immediately in onSuccess, invalidate in onSettled to also cover the error path, or use direct cache update via setQueryData when the mutation response returns the updated entity and a refetch would be redundant).

None of these five sections appear in the founding session that selects the state management library. That session answers "how do we manage state?" and closes when the first store is created or the first Context is mounted. The client state classification, the server state cache policy, the global vs local promotion criterion, the selector memoization rule, and the optimistic update policy are the operational requirements of a state management model that handles production scale, evolves across multiple engineers, and maintains consistent data freshness across every component that reads from the cache or the store. They are not advanced optimization concerns. They are the properties that determine whether adding a second engineer to the codebase introduces stale-data incidents, whether a UI that grows from 5 Context consumers to 50 Context consumers accumulates re-render performance problems, whether the 20th mutation in the codebase is as correct and consistent as the first, and whether server data in the client store accurately reflects what the backend owns. The WhyChose extractor surfaces the founding "how do we manage state?" session, the first "how do I share state?" session, the "my component is re-rendering too much" session, and the per-mutation optimistic update sessions from AI chat history; the state management ADR takes the library choice buried in those sessions and converts it into a documented state classification, a server state cache policy with measurable staleness parameters, a global vs local promotion criterion that engineers apply consistently, a memoization rule that prevents cascading re-renders as the codebase scales, and a mutation strategy that is consistent across all state-modifying operations rather than improvised per incident.

FAQs

What is the difference between client state and server state, and why does the distinction matter for state management library selection?

Server state is data owned by the backend: a list of orders, a user's profile, a workspace's members. It exists on the server, is fetched asynchronously, can become stale when the server updates it, and may be changed by other users between fetches. The client holds a cached copy with a known staleness window. Client state is data owned by the browser: a modal's open/closed condition, a form field's draft value, a selected tab index. It is created by user interaction and never true on the server.

The distinction matters because server state has a specific lifecycle — loading, error, stale, fresh, background-refetching — that general-purpose global stores do not model by default. A store that holds server state alongside UI state requires the engineer to implement loading reducers, staleness tracking, background refetch triggers, and cache invalidation logic manually. TanStack Query and SWR implement all of these behaviors as first-class concerns and leave client state to the application. A codebase that stores server state in Redux is choosing to reimplement TanStack Query's server state loop as custom application code — and that code accumulates as reducers, selectors, thunks, and staleness middleware equivalent in scope to the library it is replacing.

The founding session that selects "Redux for all state" typically does not perform this classification explicitly. The first API call's response is stored in a Redux slice because Redux is the selected state tool — not because Redux was evaluated as the correct tool for server-owned data. A state management ADR that classifies each state category before selecting a library routes server state to a server state library and reserves the global store for state the client owns.

When is Redux justified in a new project, and when has it become the wrong choice?

Redux remains justified for: complex multi-entity UI state with cross-entity relationships requiring normalization; large teams where unidirectional data flow and the Redux DevTools time-travel debugger reduce coordination costs and debugging time for complex state sequences; server-side rendered applications where the initial state must be serialized and rehydrated from a single serializable store tree.

Redux is the wrong choice when: it is selected from familiarity rather than from an evaluation of the state categories in the application; more than half of the reducers manage request lifecycle states (loading, error, success) for API calls, which means the primary problem being solved is server state management and TanStack Query would eliminate those reducers entirely; or the team is small enough that the boilerplate overhead — action types, reducers, selectors, thunks — exceeds the organizational benefit of predictable unidirectional data flow. Redux Toolkit reduces the syntactic boilerplate significantly but does not eliminate the structural cost: all state reads go through selectors, all writes go through dispatched actions, and all async operations are expressed as thunks. For applications where most global state is server-owned and most mutations are API calls with optimistic updates, TanStack Query with Zustand for the remaining client state provides the same correctness guarantees with substantially less per-feature overhead.

What should a state management ADR document that a general architecture overview does not?

A general architecture overview names the state management library. A state management ADR must document the structural properties the library selection establishes but does not enforce: (1) The client state vs server state classification — which state categories in this application are server-owned and which are client-owned, and which library is selected per category. (2) The global vs local promotion criterion — the explicit rule for when component-scoped state should be promoted to the global store, applied consistently rather than per-engineer judgment. (3) The derived state memoization policy — which library is used for memoization, and the rule for when a computation must be memoized (filtering, sorting, or aggregating collections above a threshold size). (4) The server state cache policy — the stale time and GC time per resource category, the refetch trigger configuration, and the cache key schema that determines which invalidation targets which cached data. (5) The optimistic update strategy — which mutations use optimistic updates, the rollback mechanism, the cache invalidation timing after successful mutations, and the user-visible error UX when an optimistic update is rolled back. None of these appear in the founding sprint session that selects the library; all of them determine whether the second engineer adds a feature correctly or accumulates a stale-data incident, a re-render cascade, or a 50-line one-off mutation implementation that should have been 15 lines of documented pattern.