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

entriesapp-architecture · verified 2026-07-10 · react + react-native

Client & server state management

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

cited by: data

re-verified 3× — 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 favorite36.7M/wk · ships in 4/34
Jotaiatomic, fine-grained subscriptions5.5M/wk · ships in 4/34
Redux Toolkit (2.x)structured, devtools, larger surface; React-Redux `connect` HOC deprecated → use hooks23.6M/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

npm weekly downloads (from the corpus's last signals run): zustand 36.7M · jotai 5.5M · @reduxjs/toolkit 23.6M · react-redux 24.7M · redux 36.8M · xstate 3.8M · valtio 1.7M · mobx 3.1M

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.

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 · networking · crossplatform · desktop