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

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

Data fetching & caching

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

related decisions: state · p2p · meta-frameworks

cited by: state · p2p · networking · storage

re-verified 9× — 2026-10-08 · 2026-09-24 · 2026-09-01 · 2026-08-24 · 2026-08-18 · 2026-08-07 +3 · changelog

recommendation

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).

  • GraphQL backend → Apollo Client (normalized cache, batteries included), else urql (lighter, composable)
  • 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

optiontradeoffevidence
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 API82.8M/wk · ships in 11/34
SWRLighter, 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 QueryServer-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.37.6M/wk · ships in 1/34
tRPCEnd-to-end type-safe RPC (no schema/codegen) when client + server are both TS; not for public/polyglot APIs.
TanStack DB / Zero / TinyBase / RSCLocal-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-FIRST263k/wk · ships in 1/34

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): @tanstack/react-query 82.8M · swr 22.3M · @apollo/client 7.6M · urql 1.4M · @rocicorp/zero 263k · @tanstack/react-db 1.1M

Verified notes

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.

Real-time and offline are the same problemMatthieu Hochlander (Marmelab)

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.

Reliable Query Prefetching with TanStack RouterDominik Dorfmeister (TkDodo)

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.

Coordinating Optimistic Updates in Next.jsAurora Scharff

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.

Creating Query AbstractionsDominik Dorfmeister (TkDodo), TanStack Query maintainer

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'.

TanStack Router and QueryDominik Dorfmeister (TkDodo)

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.

Why You Want React QueryDominik Dorfmeister (TkDodo), TanStack Query maintainer

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.

From latency to instant: modernizing GitHub Issues navigation performanceGitHub Engineering

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.

The Best Loading States Are No Loading StatesJenna Smith (jjenzz)

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.

use(): the hook that breaks the rules (on purpose)Sascha Becker

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.

Real-time cache invalidation (SSE + tRPC + Redis + BullMQ)Armand Sallé

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.

What Does a GraphQL / Server-State Client Cost You Per Request?Andrei Calazans

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.)

You Should Not Use Relay for EverythingAndrei Calazans

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.

Sources

The full explanation

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).


See also: RB-E-STATE (client vs server state), RB-E-META-FRAMEWORKS (RSC / loaders), RB-E-REACT-CORE (use() + Suspense), RB-E-CROSSPLATFORM (sharing the data layer).

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