Decision — use TanStack Query ⭐ 50k (Jul 2026)[2]. It is the only option in the list that gives you a real staleness model, prefix-based invalidation, optimistic rollback, paginated + infinite queries, and devtools without dragging in a router or a store[1]. Its Register.defaultError declaration-merging hook lets you make a ForgeApiError (wrapping an RFC 9457 ProblemDetails body) the global error type once — every useQuery/useMutation in the monorepo then infers it with zero generics[8].
Pick something else only if: you already run Redux (→ RTK Query[11]); the app is read-only and 8 kB gzip matters more than features (→ SWR[14]). Route loaders are not a cache — React Router keeps data for active routes only, has no stale-time, no dedup window, no devtools[1]. fetch-in-useEffect is not a candidate; it is the boilerplate all four libraries exist to delete.
The five options, scored
| Axis | TanStack Query | RTK Query | SWR | Route loaders | fetch + useEffect |
|---|---|---|---|---|---|
| ⭐ Stars (Jul 2026) | 50k [2] | 11k [4] | 32k [3] | 56k [5] | — |
| npm / week | 61.2M [10] | 23.6M (whole RTK) [10] | 13.2M [10] | 42.6M [10] | — |
| Bundle (gzip) | 13.3 kB [9] | ~13 kB + Redux + react-redux (~31 kB total) [31] | 5.0 kB [9] | free (router already needed) | 0 |
| Cache key model | Hierarchical key → value (prefix match) [1] | endpoint + serialized args [11] | Unique key → value (flat) [1] | Nested route → value, active routes only [1] | none |
| Stale-time config | ✓ [1] | ✓ [1] | ✗ (only a 2 s dedupingInterval) [14] |
✗ | ✗ |
| Auto garbage collection | ✓ | ✓ | ✗ [1] | N/A | ✗ |
| Request dedup | ✓ (by query key) | ✓ (by endpoint+args) | ✓ 2 s window [14] | ✓ per navigation | ✗ (double-fire in StrictMode) |
| Invalidation | Prefix / exact / predicate [19] |
Declarative tags by type/id [11] | Manual mutate(key) [15] |
Automatic full-page revalidate after an action [16] | hand-rolled |
| Optimistic + rollback | ✓ onMutate snapshot → onError restore [20] |
✓ onQueryStarted + patchResult.undo() |
✓ mutate with optimisticData/rollbackOnError |
✗ (fetcher only, no cache to patch) | ✗ |
| Paged (keep previous) | ✓ placeholderData: keepPreviousData [21] |
✓ | ✓ keepPreviousData [14] |
✗ (route swap = blank) | ✗ |
| Infinite / bi-directional | ✓ / ✓ [1] | ✓ / ✓ | ✓ / 🔶 | ✗ [1] | ✗ |
| Devtools | Dedicated, dev-only bundle [25] | Redux DevTools | None built-in [31] | ✗ [1] | ✗ |
| Global typed error | Register.defaultError [8] |
Union FetchBaseQueryError, needs type guards [12] |
error: any unless generic per call [14] |
Untyped useLoaderData + ErrorBoundary |
✗ |
| Coupling | None — router-agnostic, store-agnostic | Requires Redux store + <Provider> [11] |
None | Requires a data router [16] | None |
| Suspense | ✓ | ✗ [1] | ✓ | ✓ | ✗ |
Why the runners-up lose, in one line each
RTK Query — technically the closest competitor, and its tag-based invalidation (providesTags/invalidatesTags) is genuinely more declarative than manual key invalidation[11]. But its own docs say the entry price is a Redux store plus react-redux as a peer dependency[11], and its error type is the union FetchBaseQueryError — {status: number, data: unknown} plus three string-status variants — so every ProblemDetails read starts with an isFetchBaseQueryError guard and a cast off unknown[12]. There is no global-error-type registration either: the error shape is "dictated by the baseQuery provided", and cross-cutting error handling means writing Redux middleware around isRejectedWithValue[13]. Adopting Redux purely to get RTK Query is exactly what the ecosystem tells you not to do[31]. It also has no Suspense support[1].
SWR ⭐ 32k, 5.0 kB gzip — half the bundle, a quarter of the model. No stale-time (only a 2 s dedupingInterval), no selectors, no garbage collection, no query cancellation[1][14]. It revalidates on focus + reconnect by default and expects you to hand-mutate cache keys[15]. Fine for a read-heavy dashboard; wrong for a CRUD admin app where mutation → invalidation → optimistic rollback is the core loop. Still actively maintained (2.4.2, commits Jul 2026)[3] — not a health risk, just a smaller feature set.
Route loaders (React Router 8 / TanStack Router) — a loader is a fetch scheduler, not a cache. React Router revalidates every loader on the page after any successful action, and gives you shouldRevalidate to opt out[16]; there is no staleness tracking, no cache persistence beyond active routes, no polling, no infinite queries, no devtools[1]. Loaders and TanStack Query are complementary, not alternatives — the sibling shadcn-admin stack pairs TanStack Router ⭐ 15k[32] loaders (which call queryClient.ensureQueryData) with TanStack Query as the actual store.
fetch in useEffect — no dedup (React 19 StrictMode double-invokes effects), no cancellation, no cache sharing between two components asking for the same list, and you rebuild retry/backoff by hand. TanStack Query's default is 3 retries with Math.min(1000 * 2 ** attemptIndex, 30000) backoff[24]. Not a serious candidate for a greenfield admin app.
The ProblemDetails pattern (the part that actually matters)
Forge returns RFC 9457 application/problem+json for every 400/500 — type, title, status, detail, instance[6], plus ASP.NET Core's traceId extension member, and — for model-validation failures — a ValidationProblemDetails with an errors: Record<string, string[]> dictionary[27][7].
1. A typed error, thrown from the fetch wrapper
// libs/api/src/problem-details.ts
export interface ProblemDetails {
type: string
title: string
status: number
detail?: string
instance?: string
traceId?: string // ASP.NET Core extension member; correlate with Loki
}
export interface ValidationProblemDetails extends ProblemDetails {
errors: Record<string, string[]> // ModelState key -> messages
}
export class ForgeApiError extends Error {
constructor(readonly problem: ProblemDetails) {
super(problem.detail ?? problem.title)
this.name = 'ForgeApiError'
}
get status() { return this.problem.status }
get fieldErrors() {
return (this.problem as ValidationProblemDetails).errors as
Record<string, string[]> | undefined
}
}
export async function forgeFetch<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
...init,
// ASP.NET's DefaultProblemDetailsWriter only honours application/json,
// application/problem+json and wildcards — any other Accept falls back to
// a non-Problem body. [[7]]
headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...init?.headers },
})
if (!res.ok) {
const ct = res.headers.get('content-type') ?? ''
const problem: ProblemDetails = ct.includes('problem+json') || ct.includes('json')
? await res.json()
: { type: 'about:blank', title: res.statusText, status: res.status }
throw new ForgeApiError(problem)
}
return res.status === 204 ? (undefined as T) : ((await res.json()) as T)
}
2. Register it globally — no generics anywhere else
This is the payoff line. Declaration-merging Register.defaultError sets "a global Error type for everything, without having to specify generics on call-sides"[8]. RTK Query and SWR have no equivalent.
// libs/api/src/query-client.ts
import { QueryCache, QueryClient } from '@tanstack/react-query'
import { ForgeApiError } from './problem-details'
declare module '@tanstack/react-query' {
interface Register {
defaultError: ForgeApiError
}
}
export const queryClient = new QueryClient({
// Fires once per failed query — the right place for toasts. Only toast on
// background refetch failures; a first-load failure is the component's job. [[17]]
queryCache: new QueryCache({
onError: (error, query) => {
if (query.state.data !== undefined) {
toast.error(error.title ?? error.message, { description: error.problem.detail })
}
},
}),
defaultOptions: {
queries: {
staleTime: 30_000,
// 4xx is a contract violation, not a blip — retrying it is pure latency. [[24]]
retry: (failureCount, error) => error.status >= 500 && failureCount < 3,
},
},
})
After this, const { error } = useQuery(...) gives ForgeApiError | null, and error.fieldErrors type-checks. No unknown, no guards, no per-call generic — the exact thing RTK Query's isFetchBaseQueryError ceremony exists to work around[12].
3. Mutation: invalidate, roll back, and pipe 400s into the form
// The onMutate/onError/onSettled shape is the documented rollback pattern. [[20]]
const useUpdateUser = (form: UseFormReturn<UserForm>) =>
useMutation({
mutationFn: (user: User) =>
forgeFetch<User>(`/api/users/${user.id}`, { method: 'PUT', body: JSON.stringify(user) }),
onMutate: async (user, context) => {
await context.client.cancelQueries({ queryKey: ['users', 'detail', user.id] })
const previous = context.client.getQueryData(['users', 'detail', user.id])
context.client.setQueryData(['users', 'detail', user.id], user)
return { previous }
},
onError: (error, user, onMutateResult, context) => {
context.client.setQueryData(['users', 'detail', user.id], onMutateResult.previous)
// ValidationProblemDetails.errors maps 1:1 onto react-hook-form's setError.
// .NET emits PascalCase ModelState keys; the form fields are camelCase.
if (error.status === 400 && error.fieldErrors) {
for (const [key, messages] of Object.entries(error.fieldErrors)) {
form.setError(camelCase(key) as keyof UserForm, { message: messages.join(' ') })
}
}
},
// Prefix match: one call re-fetches ['users','list',*] AND ['users','detail',id]. [[19]]
onSettled: (_d, _e, user, _r, context) =>
context.client.invalidateQueries({ queryKey: ['users'] }),
})
4. Pagination against ForgePagedResult
A paged-result contract is a page-based query, not a cursor-based one. Put the page in the key and use placeholderData: keepPreviousData so the table doesn't blank between pages[21]. queryOptions keeps the inference intact when you hoist options out of the component[8].
// Mirror ForgePagedResult / ForgePageQuery field names exactly.
export interface ForgePagedResult<T> {
items: T[]
page: number
pageSize: number
totalCount: number
}
export const usersPage = (q: ForgePageQuery) =>
queryOptions({
queryKey: ['users', 'list', q] as const,
queryFn: () =>
forgeFetch<ForgePagedResult<User>>(
`/api/users?page=${q.page}&pageSize=${q.pageSize}&sort=${q.sort ?? ''}`,
),
placeholderData: keepPreviousData,
})
// In the table:
const { data, isPlaceholderData } = useQuery(usersPage({ page, pageSize: 25 }))
// isPlaceholderData -> disable the "next" button while the new page is in flight [[21]]
If a screen ever needs infinite scroll instead, the same endpoint works with useInfiniteQuery: initialPageParam: 1, getNextPageParam: (last) => last.page * last.pageSize < last.totalCount ? last.page + 1 : undefined; results arrive as data.pages[] / data.pageParams[], and maxPages caps retention[22].
5. Jest + MSW: assert on the ProblemDetails body, not on a mocked module
TkDodo's rule: mock at the network layer with MSW ⭐ 18k[28] so the mock is one source of truth across tests, Storybook and dev[18]. Fresh QueryClient per test with retry: false, otherwise the 3-retry default times the test out[23]. Under Jest specifically, set gcTime: Infinity to avoid the "Jest did not exit" warning[23].
const server = setupServer(
http.put('/api/users/:id', () =>
HttpResponse.json(
{
type: 'https://tools.ietf.org/html/rfc9110#section-15.5.1',
title: 'One or more validation errors occurred.',
status: 400,
errors: { Email: ['The Email field is not a valid e-mail address.'] },
},
{ status: 400, headers: { 'Content-Type': 'application/problem+json' } },
),
),
)
const createWrapper = () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
})
return ({ children }: PropsWithChildren) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
)
}
it('maps ValidationProblemDetails onto form fields', async () => {
const { result } = renderHook(() => useUpdateUser(form), { wrapper: createWrapper() })
result.current.mutate({ id: 1, email: 'nope' })
await waitFor(() => expect(result.current.isError).toBe(true))
expect(result.current.error!.fieldErrors!.Email).toHaveLength(1) // typed, no cast
})
Does this kill the need for a client-state library?
Mostly. TanStack Query's own position: "for a vast majority of applications, the truly globally accessible client state that is left over after migrating all of your async code to TanStack Query is usually very tiny"[26]. In a CRUD admin app the residue is theme, sidebar collapse, and maybe a command-palette flag. That is useState + a context, or — if you want one store — Zustand ⭐ 59k[29], which is what the shadcn-admin reference stack uses. Table filters/sort/page belong in the URL, not a store. Do not adopt Redux.
Caveats and second-order calls
- ⚠ The
contextargument inonMutate/onError/onSettledis the current-docs signature (context.client.setQueryData(...))[20]. Older tutorials close over auseQueryClient()handle instead. Both work; pick one and be consistent acrosslibs/. - ⚠ Prefix invalidation is a blunt instrument.
invalidateQueries({ queryKey: ['users'] })nukes every page of every user list. That is correct-by-default and fine at admin-app scale; reach forpredicateonly when a screen measurably suffers[19]. - Consider generating the query layer from Forge's Swagger. Since the API already ships OpenAPI, openapi-react-query is a 1 kB type-safe wrapper over TanStack Query driven straight off the schema — path params, bodies and responses all type-checked, no hand-written DTOs[30]. It is the strongest single argument RTK Query had (its own codegen[31]), and TanStack Query has an equivalent. Trade-off: you lose the hand-rolled
forgeFetchthrow-site, so theForgeApiErrormapping moves into anopenapi-fetchmiddleware. - Devtools cost nothing in prod —
@tanstack/react-query-devtoolsis only bundled whenprocess.env.NODE_ENV === 'development'[25]. - Error boundaries: use
throwOnError: (error) => error.status >= 500to send 5xx to a boundary while leaving 4xx to render inline[17]. That split maps cleanly onto ProblemDetails'status.