Atlas survey

Server-state layer for a React 19 + Vite SPA on a .NET API: TanStack Query wins

TanStack Query is the correct server-state layer for itenium-ui; its Register.defaultError hook makes RFC 9457 ProblemDetails a first-class typed error with no per-call generics.

32 sources ~11 min read #2 react · tanstack-query · rtk-query · swr · dotnet · problemdetails · state-management · vite · typescript

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 context argument in onMutate/onError/onSettled is the current-docs signature (context.client.setQueryData(...))[20]. Older tutorials close over a useQueryClient() handle instead. Both work; pick one and be consistent across libs/.
  • 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 for predicate only 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 forgeFetch throw-site, so the ForgeApiError mapping moves into an openapi-fetch middleware.
  • Devtools cost nothing in prod@tanstack/react-query-devtools is only bundled when process.env.NODE_ENV === 'development'[25].
  • Error boundaries: use throwOnError: (error) => error.status >= 500 to send 5xx to a boundary while leaving 4xx to render inline[17]. That split maps cleanly onto ProblemDetails' status.

Citations · 32 sources

Click the Citations tab to load…