react-brain
Browse decisions
React & language foundations 3App architecture 10UI 13Platform & native 9Build, test, observe, secure 6AI in React apps 3stack composerdecision recordscensusstaleness bencharchitecturechangelogmethodologyroadmap

entriesapp-architecture · verified 2026-08-04 · react + react-native

Client & server state management

reviewedconfidence: hightrack: heldthis tier holds 83% on the public scorecard (6/11 graded · 0 overturned) →

cited by: data

re-verified 5× — 2026-08-04 · 2026-07-28 · 2026-07-13 · 2026-07-10 · 2026-07-06 · changelog

recommendation

Split the problem: TanStack Query for server state + Zustand for client/UI state. This is the common, low-regret 2026 pairing.

  • tiny app / a few shared values → useState + Context (don't add a lib)
  • complex multi-step flows / statecharts → XState
  • already on Redux and it's fine → keep RTK; don't migrate without a concrete pain
  • want fine-grained atoms → Jotai

Options & tradeoffs

the field considered — and why each one isn’t the default here

optiontradeoffevidence
useState/useReducer + Contextzero-dep; re-render scope hard to control at scale
Zustandtiny, selector-based; minimal boilerplate; a State-of-React-2025 favorite44.5M/wk · ships in 4/34
Jotaiatomic, fine-grained subscriptions4.9M/wk · ships in 5/34
Redux Toolkit (2.x)structured, devtools, larger surface; React-Redux `connect` HOC deprecated → use hooks23.4M/wk · ships in 8/34
XState (5.x) / XState Storestate machines / statecharts for complex flows; Store 4.0 is a lighter signal-style store
TanStack Store / Signalsfine-grained reactive store (alien-signals core); underpins TanStack Router/Table/Form
Legend Statefine-grained observables + built-in sync/persistence; decouples state ownership from subscription so re-renders push to leaf nodes; smaller ecosystem than Zustand/Jotai
TanStack Queryfor SERVER state/caching — pairs with a client-state lib, doesn't replace it; State-of-React favorite

evidence: npm weekly downloads (signals snapshot) · “ships in n/D” = adoption across the production-app census, honest denominators

Migration lines

version and deprecation lines this decision tracks — for YOUR repo's sequenced plan: the doctor or npx -y @heart-it/react-brain migrate .

mobx < 7.0.0 → MobX 7 [upgrade · effort M]

MobX 7 (2026-07-30) drops the ES5/non-proxy fallback entirely — Proxy-backed observables are now the only mode, so `configure({ useProxies })` and `{ proxy: false }` are gone; legacy decorators are no longer supported (Stage-3 `@observer` stays in mobx-react); namespaced properties became named exports (observable.ref → observableRef, comparer.structural → compareStructural); the public `trace` API and mobx-react's Provider/inject were removed, and the React bindings now require React 18+. Pays for itself in bundle: the ESM production build drops 17.02 → 13.96 KiB gzip

github.com/mobxjs/mobx/releases/tag/mobx%407.… · registry.npmjs.org/mobx/latest

npm weekly downloads (from the corpus's last signals run): zustand 44.5M · jotai 4.9M · @reduxjs/toolkit 23.4M · react-redux 29.7M · redux 35.8M · xstate 4.4M · valtio 1.6M · mobx 3.3M

Verified notes

Separate server state (caching/sync) from client/UI state — different problems, different tools. State of React 2025 (3,760 devs): TanStack Query the #1 React library; Zustand crossed 50% usage + tops satisfaction; ~34% use no state library. Adversarially stress-tested 2026-06-25 (SURVIVES, high): nothing has overtaken Zustand, and local-first (TanStack DB) builds on TanStack Query rather than replacing it.

Canonical reading

Editorial annotations on why each piece matters — the articles themselves are the originals; read them there.

Structural sharing, selectAtom, and why your Jotai atoms re-render too muchPeter Piekarczyk

The first Jotai-specific deep-dive here, for the entry's 'fine-grained atoms → Jotai' clause: Object.is propagation mechanics, why selectAtom is a trap (decompose into primitive atoms instead), and write-layer structural sharing à la React Query. The re-render discipline that makes atomic state actually fine-grained.

How to Build the Fastest Apps: Break the Rules (App.js Conf 2026)Jay Meistrich (Software Mansion) — author of Legend State / Legend List

The performance case for signal/observable state. React's `render` over-orchestrates: `useState`/`useContext` couple state *ownership* with *subscription*, so lifting state up and over-subscribed context cascade re-renders top-down — measured ~10x CPU vs updating one leaf node. The fix ('render once'): create state without subscribing (observables / Reanimated shared values), own it high in the tree, push subscription down so only leaf nodes re-render themselves; provide stable objects through Context so the provider never re-renders; subscribe to derived values, not raw ids. Vendor talk (his own Legend State), but the ownership-vs-subscription principle is framework-agnostic and transfers to Jotai atoms, TanStack Store/Signals, and use-context-selector.

React Query as a State ManagerDominik Dorfmeister (TkDodo), TanStack Query maintainer

The canonical case for treating server state as its own thing (stale-while-revalidate, staleTime, query-key sharing) so you stop replicating it in a client store. The source of the server-state-vs-client-state split.

Why React Context is Not a State Management ToolMark Erikson, Redux maintainer

Draws the precise line between dependency injection (Context), client-state stores (Redux/Zustand), and async/server state — the vocabulary that justifies splitting the problem instead of forcing one tool to do all three.

How is Linear so fast? A technical breakdownDennis Brotzky (performance.dev)

Breakdown of Linear's local-first architecture — IndexedDB as the primary store, optimistic updates, granular observables, aggressive code-splitting, GPU-only animation. The reference for building instant-feeling client-state apps.

Component Communication Patterns in React ApplicationsNeciu Dan

A decision framework for WHERE state should live, keyed by component proximity × data type: props/callbacks for adjacent components, lifted state or composition for siblings, context for slow-changing globals, Zustand for frequently-updated client state, TanStack Query for server data, URL params for view state, events for fire-and-forget. 'Reach for the closest tool that can actually reach the problem, and only move outward when it stops working.' The how-to-choose guide that operationalizes this entry's server-vs-client-vs-view split.

Do we need state management libraries anymore?Neci Dan (neciudan.dev)

The build-vs-buy answer behind this entry's '~34% use no state library' and 'don't add a lib' clauses. Reads Redux, Zustand, Jotai, MobX and Valtio under the hood and shows they all converge on the SAME React primitive — useSyncExternalStore — then builds a working store from scratch with selectors, showing which three problems the primitive solves for free and where the naive version loops infinitely. The point is not that libraries are useless but that you should know what ~50 lines of understood code buys you before adding a dependency: what you pay for is ergonomics, devtools and middleware, not the subscription mechanism.

The Absolute State of ManagementAlex Russell

The vocabulary attack on this entry's own subject: Russell argues that Redux, MobX, Zustand — and React itself — don't MANAGE state, they PROPAGATE it, and that real state management is about time and conflicts (the problem CRDTs and sync engines actually solve). Deliberately provocative, and Redux maintainer Mark Erikson pushed back publicly, so read it as a framing argument rather than a verdict. Its durable value here is a third axis next to the client-vs-server split: when your problem is concurrent edits and reconciliation, no propagation library is the answer — that's the local-first/sync-engine row (TanStack DB, Zero — see RB-E-DATA).

Sources

Depth (in-domain rules) is owned by the react-native-best-practices skill — this entry is selection breadth.

The full explanation

The reviewed long-form essay behind this entry — the why, not a how-to. Also on GitHub.

About state management in React & React Native

Diataxis: Explanation. This page builds understanding of the state-management decision — the reasoning behind the pick. It is not a tutorial and not step-by-step how-to: the candidate list and one-line tradeoffs live in the index entry RB-E-STATE; re-render/performance rules live in the react-native-best-practices skill. Read this to understand why; read those to do.

The one distinction that organises everything

Most "which state library?" confusion dissolves once you separate two different problems that the word "state" hides:

  • Server state — data that lives on a server and is cached on the client: it can go stale, needs refetching, deduping, retries, and invalidation. You don't own it; you mirror it.
  • Client (UI/app) state — data that is born and dies on the client: the open tab, a form draft, a theme toggle, a wizard step. You own it outright; it is never "stale."

These have opposite needs, so the leading tools specialise. TanStack Query is a server-state cache, not a client-state store — and Zustand / Jotai / Redux are client-state stores that know nothing about networks. They are complementary, not alternatives. The most common mistake — and the most common source of "Redux is bloated" or "Context is slow" complaints — is using one tool for both jobs: hand-rolling caching/refetch logic inside a client store, or stuffing transient UI flags into a global cache.

So the first question is never "Redux or Zustand?" It is "which kind of state is this?"

The default, and why

For a typical product app, the low-regret 2026 default is:

TanStack Query for server state + Zustand for client state.

This is not fashion. The State of React 2025 survey (3,760 developers) shows TanStack Query as the single most-used React library, and Zustand as the leading dedicated state manager — it crossed 50% usage (up from 28% in 2023), overtook Redux in downloads, and tops satisfaction in its category. When the most-used and most-liked tools also map cleanly onto the server/client split, recommending them is the boring-but-correct choice.

Two consequences worth internalising:

  • You often need less than you think. ~34% of survey respondents use no dedicated state library — useState/useReducer + a little useContext is genuinely enough for many apps. Reach for a library when you feel a real pain (prop-drilling, cross-tree sharing, re-render storms), not pre-emptively.
  • The pieces compose. Adding TanStack Query does not mean removing Zustand, and vice versa. A healthy app frequently runs both, each doing one job.

The landscape, and when each one wins

Treat these as reasons to deviate from the default, not a menu to agonise over.

useState/useReducer + Context — the zero-dependency baseline. Correct for local state and for genuinely low-frequency global values (theme, locale, the authenticated user read once at mount). Its weakness is also its nature: a Context value change re-renders every consumer, so it is the wrong tool for frequently-changing shared state. The failure mode is using one big Context as an app store and then fighting the re-renders — at which point you wanted a store.

Zustand — a tiny selector-based store. It wins when you want shared client state without ceremony: define a store, subscribe to slices with selectors, done. Its selector model is what keeps re-renders surgical, which is exactly where Context falls down. This is the default for most teams that have outgrown plain Context.

Jotai — atomic state, composed bottom-up from primitive "atoms." It wins when state is naturally fine-grained and derived (many small independent pieces, computed values) rather than a few coarse stores. The risk is over-atomisation: splitting state that actually belongs together adds wiring for no benefit.

Redux Toolkit (RTK) — structured, middleware-rich, with the best time-travel devtools. It wins on large teams and complex, auditable state transitions where the explicit, ceremonious flow is a feature (predictability, onboarding, debuggability). RTK fixed most of "classic Redux is boilerplate," and React-Redux now steers you to hooks — the connect HOC is deprecated. The honest guidance: if you are already on a healthy RTK app, stay; migrating to Zustand for its own sake is churn. Choose RTK new mainly when its structure earns its weight.

XState / XState Store — state machines and statecharts. This is a different axis from the others: reach for it when the hard part isn't where state lives but which transitions are legal — multi-step flows, wizards, media players, anything with modes and guarded transitions where "impossible states" are the bug class you keep hitting. XState Store (4.x) is a lighter, signal-flavoured option when you want the modelling without the full interpreter.

TanStack Store / signals — fine-grained reactive primitives (an alien-signals core) that underpin TanStack Router/Table/Form. Most apps consume these transitively via those libraries rather than adopting them directly as the app store; pick them deliberately only when you want signal-style reactivity as a foundation.

The second distinction: ownership vs subscription (the "render once" lens)

The server-vs-client split tells you which tool. A second, subtler distinction explains why some state architectures are fast and others crawl, and it cuts across every library above. useState quietly does two jobs: it creates state and subscribes the creating component to it — ownership and subscription welded together, so the owner must re-render on every change, then pass the value down to whoever uses it. useContext is blunter: it subscribes to the whole context value, not the field you read, so any change anywhere re-renders every consumer. (That is the real mechanism behind "Context is slow" above: a font-scale provider that also carries window size re-renders every text node in the app on a window resize.)

That welding is what makes docs-blessed patterns expensive. Lift state up — "one of the most common things you'll do," per the React docs — moves ownership to the top, so one leaf-level change (a single message's reply colour) cascades a render through the whole screen on its way down. Coordinating through an effect (set state → re-render → open a modal, repause a query, mark-as-read on focus) re-renders when nothing visual changed. A callback with a dependency is recreated when that dependency changes, silently re-rendering its host. None of this is a bug: render is the orchestrator of everything, not just painting. And it is not free — benchmarked, moving an update from the top of the app to the tiniest leaf cut CPU by roughly 10× (Meistrich, App.js 2026). The React Compiler removes hand-memoisation but cannot save you here: when a prop actually changes, it still flows top-down.

The fix is to stop coupling ownership to subscription. Keep ownership high where it's convenient, but hand children stable state objects — references whose identity never changes — and let each consumer subscribe to only the slice it reads and re-render itself. The coordinating screen renders once and then never again; only the leaf that changed does work. These stable objects go by signals, observables, or (in Reanimated) shared values; the label matters less than the property — creating the state doesn't subscribe you; reading it does. That is "render once": render the app and its big coordinating screens once, and let leaf nodes, effects, and callbacks update themselves.

Schematically, using the chat-reply example above:

// BEFORE — ownership + subscription fused at the top; one change re-renders the whole screen
function ChatScreen({ messages }) {
  const [replyId, setReplyId] = useState(null);          // owned here…
  return (
    <>
      {messages.map(m => (
        <Message key={m.id} m={m}
          isReply={m.id === replyId}                      // …and threaded down to every row
          onReply={() => setReplyId(m.id)} />             // setReplyId re-renders ChatScreen → ALL rows
      ))}
      <Composer replyId={replyId} />
    </>
  );
}
// AFTER — ownership stays high, subscription pushed to the leaf; only the affected row re-renders
const reply$ = observable(null);                         // stable object; its identity never changes

function ChatScreen({ messages }) {                      // renders once — it never *reads* reply$
  return (
    <>
      {messages.map(m => <Message key={m.id} m={m} />)}
      <Composer />
    </>
  );
}

function Message({ m }) {
  const isReply = use$(() => reply$.get() === m.id);     // subscribe to a *derived* slice
  return <Row highlighted={isReply} onReply={() => reply$.set(m.id)} />;  // set from anywhere, no prop-drill
}

ChatScreen never reads reply$, so it renders once; each Message subscribes to only the === m.id slice, so setting reply$ re-renders exactly the one row whose value flipped — not the list, not the screen. The observable/use$/.get()/.set() shape is Legend State's; a Jotai atom, a Zustand selector, or use-context-selector expresses the same shift. The point isn't the API — it's that creation and subscription are now separate lines.

This is neither exotic nor one vendor's trick. You already rely on it whenever a Reanimated shared value drives an animation off the render path; it is why Legend List stays smooth (a fixed pool of containers that signal themselves to re-render, instead of re-rendering the list — see RB-E-LISTS); and it is the default model of Solid, Svelte, and Preact. Within this entry's own landscape, Jotai already leans this way (atoms decouple a value from its reader) and selector stores like Zustand narrow subscriptions to slices; dedicated signal/observable libraries (Legend State, TanStack Store, and use-context-selector for Context specifically) take it furthest.

Two honest caveats. It departs from the React-docs defaults (lift-state-up, effects for coordination), so adopting it wholesale is an architectural choice a team buys into, not a drop-in — and its sharpest articulation comes from the author of Legend State, so weigh it as a lens, not a mandate; the ownership-vs-subscription reasoning survives dropping any particular library. And it stays Explanation-level here: the mechanics — selector placement, avoiding context-driven list re-renders, atomic vs coarse subscriptions — live in react-native-best-practices, and the candidate libraries in the index entry. Hold just the model: decide who owns state separately from who subscribes to it, and push the subscription as far down the tree as it goes.

Tradeoffs and failure modes to name out loud

  • "Context is slow" is usually "I used Context as a high-frequency store." The fix is a selector-based store (Zustand/Jotai), not abandoning Context for the things it's good at.
  • "Redux is boilerplate" largely predates RTK. Judge RTK, not 2018 Redux. But also don't add RTK to a small app to look serious — that's the inverse mistake.
  • Over-atomisation (Jotai) / over-storification — splitting cohesive state into many atoms or stores trades one problem (too coarse) for another (too scattered). Group state that changes together.
  • Server state in a client store — the cardinal error. If you find yourself writing isLoading, error, refetch and cache-invalidation logic by hand inside Zustand/Redux, that state wanted TanStack Query (see RB-E-DATA).

How it interacts with the rest of the stack

  • React Compiler (RB-E-REACT-CORE). With the compiler auto-memoising, the old performance argument "a store avoids re-renders you'd otherwise hand-memoise" weakens for client state — choose a store for its ergonomics and sharing model, not as a memoisation workaround. One caveat the ecosystem has flagged: mutating objects you got from useState can defeat compiler optimisation — prefer immutable updates and data-first models over behaviour-rich class instances in render paths.
  • React Native re-render performance. The depth rules — selector discipline, avoiding context-driven list re-renders, atomic vs coarse subscriptions — are owned by react-native-best-practices (e.g. its atomic-state rule). This page tells you which tool; that skill tells you how to wire it without jank.
  • Forms (RB-E-FORMS) are their own kind of state. Don't model form fields in your global store; a form library (React Hook Form, TanStack Form) owns that lifecycle.
  • Cross-platform (RB-E-CROSSPLATFORM). All the client-state options here are platform-agnostic and belong in your shared logic package, working identically on web and React Native — a good thing to centralise once.

Migrating (the short version)

Migrations between client stores are usually not worth it without a concrete pain — the libraries are similar enough that the win rarely covers the cost. The migration that does pay off is the conceptual one: pulling server state out of a client store into TanStack Query. That typically deletes code (hand-rolled caching, loading flags, refetch effects) rather than moving it, which is the signal of a good migration. Keep detailed step-by-step migration mechanics out of this page — that is a how-to concern; the principle is "move by responsibility, server-state first."

In one paragraph

Decide kind of state before library. Default to TanStack Query (server) + Zustand (client); stay on healthy Redux Toolkit; reach for Jotai when state is naturally atomic, XState when legal transitions are the hard part, and plain useState/Context when you need nothing more — which is more often than the discourse suggests. The recurring mistake is using one tool for both kinds of state. And past which tool lies how you wire it: decouple who owns state from who subscribes to it, pushing re-renders down to the leaves ("render once").


See also: RB-E-DATA (server-state / data-fetching detail), RB-E-FORMS, RB-E-REACT-CORE (Compiler interaction), RB-E-CROSSPLATFORM. Depth on re-render performance: the react-native-best-practices skill.

Related in app-architecture: data · p2p · nav · meta-frameworks · forms · auth · networking · crossplatform · desktop