Best Practices

AI Rules for State Management

AI puts everything in one global store — API data, form state, UI toggles. Rules for server vs client state separation, colocation, derived state, and the right tool per use case.

8 min read·November 20, 2024

50 fields in one global store is not state management — it is a maintenance disaster

Server/client separation, colocation, derived state, minimal global, and the right tool per use case

AI Puts Everything in One Global Store — And It Is Always Wrong

AI generates state management code where every piece of state goes into one global store: user data from the API, whether the sidebar is open, the current form values, the list of products from the database, the selected tab, and the pagination cursor — all in one Redux store or one Context provider. This creates: unnecessary re-renders (changing sidebar state re-renders the product list), complex selectors (extracting one boolean from a deep nested state tree), and maintenance nightmares (every component touches the global store for everything).

The fundamental insight: not all state is the same. Server state (data from APIs) has different characteristics than client state (UI toggles, form values). Server state is: async, cacheable, shared, and can become stale. Client state is: synchronous, local, ephemeral, and always current. Treating them the same — putting both in Redux — means managing caching, invalidation, and loading states manually for every API call. TanStack Query handles all of this automatically for server state.

These rules define: what goes where, the principle of colocation, when to derive instead of store, and how to choose between tools (TanStack Query, Zustand, Redux, Context, useState).

Rule 1: Separate Server State from Client State

The rule: 'Server state (data from APIs) goes in TanStack Query or SWR — never in Redux or Zustand. Client state (UI state) goes in Zustand, Redux, or component state — never in TanStack Query. The separation: TanStack Query manages: caching, refetching, invalidation, loading/error states, deduplication, and background updates. Global store manages: auth status, theme preference, sidebar state, and selected filters.'

For identification: 'Is this data from an external source (API, database)? → server state → TanStack Query. Is this data local to the application (UI toggle, form draft, preference)? → client state → Zustand/Redux/useState. Does the data become stale? → server state. Is the data always current because the user just set it? → client state.'

AI puts user list, loading state, error state, and selectedUserId all in Redux: createSlice({ name: "users", initialState: { users: [], loading: false, error: null, selectedId: null } }). This manual approach requires: action for fetch start, action for fetch success, action for fetch error, selector for loading, and manual cache invalidation. TanStack Query replaces 30 lines of Redux with: useQuery({ queryKey: ["users"], queryFn: fetchUsers }).

  • Server state (API data) → TanStack Query — caching, refetch, invalidation automatic
  • Client state (UI) → Zustand/Redux/useState — local, synchronous, always current
  • Never API data in Redux/Zustand — TanStack Query manages it better
  • Never UI toggles in TanStack Query — it is for external data, not local state
  • 30 lines of Redux → 1 line of useQuery — for the same server state functionality
💡 30 Lines → 1 Line

Redux for API data: action for fetch start + success + error, selector, manual cache invalidation = 30 lines. TanStack Query: useQuery({ queryKey, queryFn }) = 1 line. Same result, automatic caching, loading states, and refetch.

Rule 2: Colocate State — As Low as Possible

The rule: 'Keep state as close as possible to where it is used. Component-local state (useState): form values, toggles, modal open/close, hover state — used by one component. Lifted state (parent useState): state shared between 2-3 sibling components. Context: state shared across a subtree (theme, locale). Global store: state shared across the entire app (auth, preferences). The lower the state, the fewer re-renders and the simpler the code.'

For the decision: 'Start with useState. When two components need the same state, lift to their closest common parent. When many components across different subtrees need it, use Context or a global store. Never start with global — start local and lift only when needed. Most state is component-local: form values, animation state, expanded/collapsed, hover, focus.'

AI puts isModalOpen in the global Redux store — used by exactly one component. When the modal state changes, every component subscribed to the store re-renders. useState in the modal component: zero global re-renders, zero store complexity, zero selector code. The simplest solution is almost always the correct one.

⚠️ isModalOpen in Global Store

AI puts isModalOpen in Redux — used by one component. When modal state changes, every subscriber re-renders. useState in the modal: zero global re-renders, zero store complexity. Start local, lift only when needed.

Rule 3: Derive State — Never Store Redundant Copies

The rule: 'If a value can be computed from other state, compute it — do not store it separately. filteredUsers = users.filter(u => u.active) — computed from users + filter criteria. totalPrice = items.reduce((sum, item) => sum + item.price * item.quantity, 0) — computed from the cart items. fullName = `${firstName} ${lastName}` — computed from two fields. Storing derived values creates synchronization bugs: update the source but forget to update the derived copy.'

For expensive derivations: 'Use useMemo for expensive computations: const sorted = useMemo(() => expensiveSort(items), [items]). Zustand: use a selector with createSelector. Redux: use createSelector from @reduxjs/toolkit. Memoization ensures the computation only re-runs when the source data changes — not on every render.'

AI stores: users, filteredUsers, sortedUsers, totalCount — four state values when one (users) plus derivations would suffice. Update users and forget to update filteredUsers → stale filter results. Derive filteredUsers from users + filter → always correct, zero synchronization bugs.

  • Derive: filter, sort, count, total, fullName — computed from source state
  • Never store redundant copies — synchronization bugs when source updates
  • useMemo for expensive derivations — only recomputes when source changes
  • createSelector (Redux/Zustand) for memoized selectors — cached between renders
  • One source of truth + derivations > multiple synchronized copies
ℹ️ Derive, Never Duplicate

Storing users + filteredUsers + sortedUsers = three copies that drift. Storing users + computing filtered/sorted = one source of truth, always correct. Derived state eliminates an entire class of synchronization bugs.

Rule 4: Minimize Global State

The rule: 'Global state should contain only: authentication status (isLoggedIn, user, tokens), user preferences (theme, locale, sidebar collapsed), and cross-cutting feature state (notification count, connection status). Everything else is either: server state (TanStack Query), component state (useState), or lifted state (parent useState). If you can delete a global state field and derive it, do so.'

For the test: 'For each field in your global store, ask: would removing this and using useState/TanStack Query instead break something that CANNOT be solved by lifting state or using a query hook? If the answer is no, the field does not belong in the global store. Most global stores have 20 fields — after this test, they have 5.'

AI creates global stores with 50 fields: API data (should be TanStack Query), form state (should be React Hook Form), modal state (should be useState), filter state (should be URL params), and pagination (should be TanStack Query). After separation: the global store has 5 fields, the rest live where they belong — simpler, faster, more maintainable.

Rule 5: Choosing the Right Tool

The rule: 'useState: component-local state — the default choice for any state used by one component. TanStack Query: all server state — API data, caching, refetch, loading/error. Zustand: lightweight global client state — auth, preferences, 5-10 fields. Redux Toolkit: complex global client state with middleware needs — large apps, saga-like orchestration. Context: dependency injection (theme, locale) — not for frequently changing state (causes re-renders in the entire subtree).'

For the hierarchy: 'Start with useState. Need it in a sibling? Lift to parent. Need it app-wide? Is it from an API? TanStack Query. Is it client state with <10 fields? Zustand. Is it complex with middleware? Redux. Need to inject a value (theme)? Context. This hierarchy — from simplest to most complex — prevents reaching for Redux when useState would suffice.'

AI reaches for Redux for everything — because Redux dominates training data. 80% of React state management needs are covered by: useState (local), TanStack Query (server), and Zustand (small global). Redux is the right answer for 10-15% of apps — large, complex, with cross-cutting state concerns.

  • useState: component-local — the default, always start here
  • TanStack Query: all API data — caching, refetch, loading/error automatic
  • Zustand: lightweight global (auth, preferences, 5-10 fields) — minimal boilerplate
  • Redux Toolkit: complex global with middleware — large apps, orchestration
  • Context: dependency injection (theme, locale) — not for frequent updates

Complete State Management Rules Template

Consolidated rules for state management.

  • Server state → TanStack Query — client state → Zustand/Redux/useState — never mix
  • Colocate: useState first → lift to parent → Context → global store — as low as possible
  • Derive, do not store: filter, sort, count, total — computed from source, never redundant copies
  • Minimal global: auth + preferences + cross-cutting only — 5 fields, not 50
  • useState for local — TanStack Query for API — Zustand for small global — Redux for complex
  • Context for injection (theme, locale) — never for frequently changing state
  • Selectors with memoization: createSelector, useMemo — prevent unnecessary re-renders
  • Start simple, add complexity only when measured — useState is almost always enough