TanStack Query (React and React Native). It caches server state for REST or any transport, and the same data-layer code runs on web and native. The pick changes with the backend: a GraphQL graph, a private all-TypeScript API, offline-first sync, server rendering, or a P2P stack (see when).
large cross-referenced entity graph with compiler-driven discipline → Relay (21; first-party TS; scope it to entity data, see reading 'You Should Not Use Relay for Everything')
client + server both TypeScript, private API → tRPC (type-safety with no codegen)
offline / local-first / collaborative → TanStack DB or Zero (a sync-engine architecture commitment, not a drop-in cache; see note: LOCAL-FIRST)
P2P / serverless / Holepunch backend → n/a (data lives in the Hypercore/Autobase stack queried locally via hrpc; a client cache does not apply by design; RB-E-P2P owns this)
server-rendered web (RSC) where data changes only when the user acts → no client cache (fetch in Server Components; useActionState + useOptimistic cover writes; web only, see reading 'Coordinating Optimistic Updates in Next.js'). Data that changes on its own (polling, pushes) keeps the default
already on Redux Toolkit → RTK Query
already on SWR, or modest needs with a hook-first preference → SWR (switching to TanStack Query rarely pays without a concrete missing feature)
Options & tradeoffs
the field considered — and why each one isn’t the default here
option
tradeoff
evidence
TanStack Query (5.x)
Cache, dedupe, retries, invalidation; transport- and platform-agnostic, so the data layer is shared web + RN code. 5.103.2 (2026-09-21, verified vs npm). See note: IMPERATIVE API
82.8M/wk · ships in 11/34
SWR
Lighter, hook-first stale-while-revalidate with a smaller surface than TanStack Query; alike in model, so pick by ergonomics, not capability.
22.3M/wk · ships in 2/34
RTK Query
Server-state cache shipped inside Redux Toolkit: one mental model and devtools if you are already on RTK. Never adopt Redux just to get it.
Apollo Client (4.x) / urql / Relay (21)
GraphQL clients; worth it only when the backend is a real graph. Relay v21 adds first-party TS + experimental RSC; Apollo 4.2 type-safe defaults, 4.3 custom scalars and a TypeScript 5.9 floor (4.3.1, 2026-09-18, verified vs npm). See note: APOLLO 4.3
7.6M/wk · ships in 1/34
tRPC
End-to-end type-safe RPC (no schema/codegen) when client + server are both TS; not for public/polyglot APIs.
TanStack DB / Zero / TinyBase / RSC
Local-first sync stores and server-fetched RSC (web only). TanStack DB 0.6 (2026-03) added SQLite-backed persistence (browser WASM, RN/Expo, Node, Electron) + offline transactions; still 0.x. TinyBase has no when-clause here. See note: LOCAL-FIRST
Server state is not client state (RB-E-STATE owns the split). Staleness, dedupe, retry and invalidation are one problem cluster, and a server-state cache solves it generically; hand-rolling it in a client store or a useEffect rebuilds that cluster with bugs. TanStack Query is the #1 React library in State of React 2025, and because it is transport- and platform-agnostic the data layer is shared code across web and RN. RSC complements the cache on the web rather than displacing it. Cost is per cache write (see reading 'What Does a GraphQL / Server-State Client Cost You Per Request?'), so keep firehose data out of the expensive path whichever client you pick.
IMPERATIVE API (2026-08-22, verified vs TanStack/query PR #10658 and npm 5.102.0): new queryClient.query() and infiniteQuery() replace fetchQuery/fetchInfiniteQuery; fetchQuery, prefetchQuery and ensureQueryData now carry @deprecated tags but still work (additive change). The new methods respect select, enabled and skipToken, which the old ones ignored, and throw when enabled is false or queryFn is skipToken with no cached data to return → use them in new imperative/prefetch code, including the route-context prefetch pattern in the TkDodo readings (which still show ensureQueryData).
APOLLO 4.3 (2026-09-11, verified vs the apollo-client GitHub release): custom scalars in InMemoryCache (a field-policy scalar option, auto-serialized variables); the minimum supported TypeScript is now 5.9.x → check the TS version before upgrading; `skip` in useQuery/useSubscription is deprecated in favor of skipToken. Current 4.3.1 (2026-09-18, verified vs npm).
LOCAL-FIRST (2026-09-24, verified vs npm): @tanstack/db 0.9.2 (2026-09-14; @tanstack/react-db 0.4.1), built on TanStack Query and still 0.x; its 0.6 (2026-03-25) SQLite persistence made real offline possible. Zero 1.9.0 (2026-08-14), with React Native support; npm dates 1.0.0 to 2026-03-24. TinyBase 10.0.1 (2026-09-24, a major; 9.0.0 was 2026-07-03). All three are an architecture commitment for products where offline or realtime is core, not a way to avoid a loading spinner. What none of them hands you (the Marmelab reading, 2026-10-08): offline and realtime are one reconciliation problem, records need client-generated IDs (UUIDv7) from day one, Zero and Verdant resolve conflicts as last-write-wins, and no library ships rollback, retry or a conflict UI — budget that work.
RSC TEST (verified vs the Aurora Scharff reading, which owns the mechanism): in a server-rendered web app a client cache earns its place only when data changes on its own; otherwise it means keeping two caches in sync. RSC does not apply to React Native.
RB-E-P2P owns the Holepunch data path; RB-E-META-FRAMEWORKS owns RSC and route loaders; RB-E-STATE owns client state.
HISTORY: 2026-10-08 GAP CLOSED: conflict resolution / client IDs / mutation recovery facet of LOCAL-FIRST — Marmelab reading (reopened on its second independent sighting).
2026-09-24 CORRECTED: PR #10658 deprecates fetchQuery as well as prefetchQuery/ensureQueryData, via @deprecated tags; npm dates Zero 1.0.0 to 2026-03-24, not 2026-06.
2026-06-25 CHALLENGED → SURVIVES (high): RSC complements the client cache; TanStack DB builds on TanStack Query; Zero 1.0 ships React Native support.
Canonical reading
Editorial annotations on why each piece matters — the articles themselves are the originals; read them there.
Treats offline support and real-time sync as one problem, divergent-state reconciliation, and walks it through Verdant, TanStack DB and Zero on two prototypes. Concrete consequences: local mutations need client-generated IDs (UUIDv7 suggested), so allowing them later is a breaking migration; Verdant and Zero effectively resolve conflicts as last-write-wins and none of the three offers built-in rollback, retry or user-facing conflict choices; TanStack DB leaves the sync layer to you. Prototype-based (no production data) and written by the React Admin maintainers, which the article does not hide.
Part 4 of the series ('TanStack Router and Query' below is part 3), and it names that pattern's failure mode at scale: the route loader and the component fetch from two places that must stay 100% in sync, there is no error when they diverge, and drift means prefetching data you no longer use or waterfalling data you forgot to prefetch. The fix is to create queryOptions in the route's `context` function — the loader calls ensureQueryData(context.xQueryOptions) and components consume the SAME object via Route.useRouteContext(), so one definition feeds both sides; child routes see every parent's queryOptions through context inheritance. Addresses the re-render worry directly: the context function re-runs only when params or loaderDeps change, so unrelated search-param churn doesn't re-render context subscribers.
Answers the question this entry's 'skip a client cache where you can' clause leaves open — when DO you need one — from the other side: two real apps built without one. The pattern is useActionState as an async reducer holding the confirmed-plus-queued state (React queues Actions, so overlapping writes apply in order instead of racing) with useOptimistic layering the in-flight change on top; rollback needs no reverse-change calculation because when the Transition ends the optimistic layer simply disappears and the server data shows through. Scaling it past one component means lifting the queue and the optimistic state into a context provider so sibling subtrees see the same pending change. The criterion at the end is the durable part: reach for TanStack Query or SWR when the data changes ON ITS OWN (messages arriving while you read), not when it only changes because the user acted (a dragged channel layout) — and be honest that adopting one in an RSC app leaves you coordinating TWO caches, invalidating the server cache inside the Server Function and the client keys in the browser. The author's own closing caveat: reducer + Action queue + optimistic state is a fair amount of wiring.
Maintainer-authored architecture guidance: queryOptions objects (not custom hooks) are THE abstraction layer for queries — composable, framework-portable, and reusable across useQuery/prefetch/suspense call sites. The how-to-structure companion to 'Why You Want React Query'.
The selection question this entry's TanStack rows raise: Router's cache is per-route, Query's is global — when route-loader caching alone suffices, when you want the full query cache, and how to layer them without double-fetching. Maintainer-authored.
Takes a naive fetch-in-useEffect and exposes the five real bugs it hides (race conditions, missing loading/empty states, stale data+error pairs, StrictMode double-fire), then shows how a server cache fixes them. The durable answer to why-not-just-useEffect.
Engineering case study on local-first caching, stale-while-revalidate, service-worker hard-navigation, and preheating vs naive prefetch, with measured percentile gains. Durable client-side data-caching patterns at scale.
Argues loading UI belongs at the app/route level (transitions + preload-on-hover/intersection) rather than scattered component spinners, using blank regions as a preload diagnostic. Durable, framework-agnostic data-loading-UX thinking.
Deep-dive on the use() hook — unwrapping promises/context at render under Suspense, the promise-identity caching requirement, and why it can legally run conditionally unlike other hooks. The modern data-fetching primitive explained.
A full architecture for pushing invalidation EVENTS (not data) over SSE to drive TanStack Query cache updates, with reference-counted subscriptions and horizontal-scaling guidance. The real-time dimension the other DATA readings don't cover.
The only PRICE TAG on this entry's options: one Expo app (SDK 57 / RN 0.86 / React 19, New Arch) built six times behind an identical DataLayer contract against Coinbase's public GraphQL API, Maestro-driven, on a low-end Samsung A16, measured with Flashlight plus a Hermes sampling profiler started at bundle load. Library self-time summed across three flows — Vanilla 0ms, Jotai 122ms, Zustand 277ms, Relay 804ms, TanStack Query 904ms, RTK Query 3218ms. The durable finding is the SHAPE, not the ranking: cost is per CACHE WRITE, so a network response, a live tick and an optimistic update each pay it — RTK Query's number explodes only in the flow with WebSocket price ticks. Pick for the features you need, then keep firehose data out of the expensive path. (Reproducible: AndreiCalazans/GraphqlClientComparison.)
The counterweight to this entry's 'Relay for scale' row, from the same author as the cost benchmark above: Relay's normalized store earns its cost only for cross-referenced entities — for client config, index-based experiments, volatile time-series ticks, server-driven UI trees and inlined assets, normalization is pure overhead (an 18MB Relay store profiled in production), and a plain store with its own fetch/refresh/persist policy is strictly cheaper. Scoping guidance, not anti-Relay: use it where cross-entity consistency pays.
The reviewed long-form essay behind this entry — the why, not a how-to. Also on GitHub.
About data fetching & server state in React & React Native
Diataxis: Explanation. This page explains how to think about the data layer and
why the default is what it is. The candidate list with one-line tradeoffs lives in the
index entry RB-E-DATA; this is its companion. It is not step-by-step how-to.
Server state is the actual problem
A fetch() is easy. What is hard is everything around it: the response can go stale,
two components asking for the same thing should dedupe, a failure should retry, a
mutation elsewhere should invalidate the cached copy, and the screen should show
loading / error / empty states without you hand-wiring a boolean soup. That cluster of
problems is server-state management, and it is the reason a dedicated library exists at
all. (The companion distinction — server state vs client state — is the spine of
RB-E-STATE; read that first if it isn't second nature.)
The recurring anti-pattern is solving server-state problems inside a client store
(Zustand/Redux): you end up re-implementing caching, isLoading flags, and refetch
effects by hand. The moment you see that code, the state wanted a server-state tool.
The default, and why
TanStack Query for REST / server-state caching. It is the default for client-side
data fetching, and a healthy app pairs it with a client-state lib (it does not replace
one).
This is the consensus pick, not a hunch: TanStack Query is the single most-used library
in the React ecosystem in State of React 2025. It earns that by doing the hard cluster
above generically — cache, dedupe, background refetch, retry, invalidation — while staying
transport- and framework-agnostic (REST, GraphQL, RPC; web and React Native alike).
That last property matters for react-brain's thesis: your data layer is mostly the same
code on web and native, so it belongs in a shared package (RB-E-CROSSPLATFORM).
The landscape, and when each one wins
Treat these as reasons to deviate, keyed to what your backend is and how your client
talks to it.
SWR — the lighter, hook-first cousin. Same stale-while-revalidate model, smaller
surface. A reasonable choice when your needs are modest and you value minimalism over
TanStack Query's breadth of features. The two are more alike than different; pick by
ergonomic taste, not capability anxiety.
RTK Query — server-state caching that ships inside Redux Toolkit. The deciding
factor is sociological, not technical: choose it when you are already on RTK and want
one mental model and one devtools surface. Do not adopt Redux in order to use RTK
Query — that's the tail wagging the dog; use TanStack Query standalone instead.
GraphQL clients — Apollo Client / urql / Relay — these win only when your backend is
actually a GraphQL graph. Apollo is batteries-included with a normalized cache; urql is
lighter and more composable; Relay is compiler-driven and the most opinionated — it now
ships first-party TypeScript and experimental RSC support, and pays off at scale and with
Meta-style data-masking discipline, at the cost of a steeper model. Reaching for a GraphQL
client over a REST endpoint is choosing a query language, not just a cache.
tRPC — end-to-end type-safe RPC with no schema and no codegen, when client and
server are both TypeScript and the API is private to your app. Its superpower is that
your server's types are your client's types — change a procedure, the call site fails to
compile. Its boundary is the flip side: it deliberately couples client and server, so
it is the wrong choice for a public API or a polyglot/mobile-shared backend consumed by
non-TS clients.
Local-first / sync engines — TanStack DB, Zero, TinyBase — a different paradigm: the
client holds a queryable local store that syncs with the server, so reads are instant
and the app works offline and collaboratively. This is powerful for the right product
(Linear-style apps), but it is an architecture commitment, not a drop-in cache — adopt
it because offline/realtime is a core requirement, not to avoid a loading spinner.
RSC (web only) — when components render on the server, the server can fetch directly
and you may not need a client-side cache for read paths at all. This shifts the question
from "which client cache?" to "what runs where?" (see RB-E-META-FRAMEWORKS). The catch
lives on the mutation path — Server Functions have a real DoS-CVE history (RB-E-SECURITY)
— and RSC is a web concern; it does not apply to React Native.
The sharper test for whether an RSC app needs a client cache at all is whether the data
changes on its own (polling, pushes). If it changes only when the user acts,
useActionState + useOptimistic cover writes with no client cache; adding TanStack Query or
SWR there means keeping two caches (server and client) in sync.
P2P / Holepunch backends — a client cache does not apply by design: the data lives in the
Hypercore/Autobase stack and is queried locally (see RB-E-P2P).
Tradeoffs and failure modes to name out loud
Hand-rolled caching in a client store — the cardinal error (see above). Move it to a
server-state tool; this usually deletes code.
GraphQL without a graph — adopting Apollo/Relay against plain REST endpoints buys
query-language overhead with little of the benefit. Use it when the graph is real.
tRPC across a trust/language boundary — great inside one TS codebase; wrong for public
or polyglot APIs precisely because of the coupling that makes it nice internally.
Request waterfalls — fetching sequentially down the tree (each child awaiting its
parent) is a latency tax; prefetch on intent (route loaders, hover) and parallelize.
Local-first as a quick win — it is a paradigm, not a patch; the migration cost is real.
How it interacts with the rest of the stack
Client state (RB-E-STATE) — the two are complementary halves. Server state →
TanStack Query; client/UI state → Zustand/Jotai. Don't merge them.
Meta-frameworks (RB-E-META-FRAMEWORKS) — RSC and framework loaders (Next.js,
TanStack Start, React Router) are also data-fetching surfaces; on the web your data
strategy and your framework choice are entangled.
React core (RB-E-REACT-CORE) — use() + Suspense let you read async resources at
render time, which dovetails with how modern data libraries expose data.
Cross-platform (RB-E-CROSSPLATFORM) — because TanStack Query is platform-agnostic,
your fetching/caching logic is shared code; put it in the common package, not per-platform.
Migrating (the short version)
The migration that pays off is pulling server state out of a client store into TanStack
Query — it typically removes more code than it adds (the hand-rolled cache, loading
flags, and refetch effects all disappear). Migrations between server-state libraries
(SWR ↔ TanStack Query) rarely justify themselves without a concrete missing feature.
Step-by-step mechanics are a how-to concern; the principle is "consolidate server state
into one cache, by responsibility."
In one paragraph
The data layer's hard problem is server state — staleness, dedupe, retry,
invalidation — so use a tool built for it: default to TanStack Query (the most-used
React library), pair it with a client-state lib, and keep server state out of that
store. Deviate for a reason: RTK Query if already on Redux, a GraphQL client if you
truly have a graph, tRPC for a private all-TypeScript API, local-first engines when
offline/realtime is core, and RSC when the server can fetch for you (web only).