← Default view
Itenium.Forge · itenium-ui · state architecture

Four layers,
one cache,
no store to speak of.

Six angles ran independently on the same question — is Redux still the answer? — and all six came back with the same drawing. This sheet is that drawing: what each layer owns, what it costs, and where the seams are.

DrawingST-2026-01
Issued2026-07-13
SubstrateNx 21 · React 19
Vite · Tailwind 4
Serves.NET 10 REST
ProblemDetails · JWT
Sheets6 · 148 citations
StatusIssued for construction

Do not introduce Redux. Generate the client from Forge's OpenAPI with Orval, let TanStack Query own server state, let TanStack Router own filter/sort/page state as typed URL search params, and leave one small Zustand store for theme, sidebar and column visibility.

Redux's own FAQ still says "don't use Redux until you have problems with vanilla React" [1]. An admin console over a REST API does not have those problems. Redux is not dying — 23.6M weekly downloads, quarterly releases [4] — there is simply no bucket of state left for it to own.

REDUX not specified

SECTION A–A The stack in elevation

Read top-down: a component renders, a hook reads the cache, the cache is filled by the generated client, the client talks to Forge. Every band names the state it owns — and no band is a global store.

Routes & components · React 19 · Tailwind 4
04
Zustand client store · the residue

Owns: theme · sidebar collapsed · column visibility. One vanilla createStore in a shared Nx lib, wrapped in persist [32]. No Provider. ~12 lines. If it grows past that, something upstream is misplaced.

size 1.2 kB gzip
github ⭐ 59k
pitfall object-returning selectors loop in v5 — wrap in useShallow [41]
03
TanStack Router URL state · typed search params

Owns: filters · sort · page · selected row — validated against a per-route schema, with every navigate() type-checked against it [23]. This is the single largest fake use-case for a store: put filters in a store and they stop being shareable, bookmarkable or refresh-proof, and the back button breaks [3]. Also owns the auth guard: beforeLoad + redirect() on a parent route protects the whole subtree [33].

size 39.6 kB gzip [18]
github ⭐ 15k
note the heaviest single line item on this sheet — and the one that deletes the most store code
02
TanStack Query load-bearing · server state

Owns: nearly everything. Caching, staleness, dedupe, retries, invalidation, optimistic rollback. Its own docs are the origin of this whole decision: once async code moves into the cache, the truly global client state left over "is usually very tiny" — their worked example reduces to themeMode and sidebarStatus [2]. Declaration-merge Register.defaultError once and RFC 9457 ProblemDetails [25] becomes the typed error of every hook, with no per-call generics [24].

size 13.3 kB gzip [19]
github ⭐ 50k
npm / wk 61.2M [21]
tests MSW at the network edge, fresh QueryClient per test [36]
01
Orval generated · not written

Owns: hooks · query keys · Zod schemas · MSW mocks — all emitted from Forge's OpenAPI document by one config [37]. Per operation you get a request fn, a key factory and a typed hook [8]. One custom mutator exports an ErrorType alias and ProblemDetails becomes the error type of every generated hook [9]. Wire it as an Nx target so the codegen is cached on its inputs [46].

github ⭐ 6.2k
why only generator emitting hooks + keys + mocks + Zod from one config
watch v8 nullable-only schemas emit invalid TS [43]
Itenium.Forge · .NET 10 REST · ProblemDetails [34] · JWT via Keycloak / OpenIddict

Session sits alongside, not inside: react-oidc-context ⭐ 1.0k already exposes the access token as a React Context. Copying it into a store creates a second copy that goes stale. Keep it in memory — never localStorage, which OWASP forbids outright for session material [38].

DETAIL A The layers compose — that is the actual finding

These are not four independent picks that happen to co-exist. The router is explicitly designed as "a perfect coordinator for external data fetching and caching libraries" [7]. Follow one navigation through the joint:

Route search params
?status=open&page=2
validated by schema
loader()
queryClient
.ensureQueryData()
Query cache
keyed by those same params
← useSuspenseQuery() reads it
The typed search params ARE the query key.
One schema. One source of truth. No slice, no action, no reducer, no selector — and the URL is the state, so it is shareable, bookmarkable and refresh-proof by construction.
// route validateSearch: (s) => issueFilters.parse(s), // Zod, emitted by Orval loader: ({ context, deps }) => context.queryClient.ensureQueryData(getIssuesQueryOptions(deps)), // component — same key, same cache entry, zero prop drilling const { data } = useSuspenseQuery(getIssuesQueryOptions(useSearch()))

And the layer below generates getIssuesQueryOptions from the OpenAPI document — so the code that would have been the store mostly ceases to exist [8]. Invalidation stays coarse and prefix-matched [45]; the one thing to avoid is a 401-retry interceptor fighting Query's own retry [48].

SCHEDULE 1 Bill of materials

Four new runtime dependencies plus a codegen step. Three of them ship from one vendor — that is the concentration risk, stated plainly.

PackageLayerGzipStarsOwns
orval 01 · codegen devDep ⭐ 6.2k Hooks, query keys, Zod schemas, MSW mocks
@tanstack/react-query 02 · server state 13.3 kB ⭐ 50k Every byte that came from the API
@tanstack/react-router 03 · URL state 39.6 kB ⭐ 15k Filters, sort, page, selection, route guards
zustand 04 · client store 1.2 kB ⭐ 59k Theme, sidebar, column visibility
react-oidc-context — · session in-memory ⭐ 1.0k Access token (Context, not a store)

SCHEDULE 2 Considered & not specified

Each of these was the leading candidate on at least one sheet. None of them is bad; each loses to the drawing above for a specific, stated reason.

The three moats fell. Time-travel through the Redux DevTools extension is now a Zustand middleware [5]; Redux's own testing guide calls the store "an implementation detail" that often needs no explicit tests [6] — so a TDD mandate is not an argument for it either.
RTK Query
Its own comparison page positions it as the pick when you already use Redux, and concedes it does not normalize or deduplicate cached items [30]. It also has no Suspense support [31]. Greenfield, that premise never holds.
SWR ⭐ 32k
Alive and 5 kB, but no staleTime, no selectors, no garbage collection, and cache invalidation is a manual flat map [31]. An admin console with filters and pagination outgrows that in a month.
React Router ⭐ 56k
The safe default, and 59 kB gzip — heavier than TanStack Router. But it does not give you a validated, type-checked search-param schema, which is the entire mechanism that kills the filter slice. Loaders cache active routes only [31].
Jotai ⭐ 21k
Excellent, and pointless here. Atomic bottom-up composition earns its keep when client state is a dense graph. After layers 01–03, the client state is three booleans and a string.
Hey API ⭐ 5.1k
The closest call on the sheet. It emits composable queryOptions objects, which is where the ecosystem is drifting; Orval's one-hook-per-endpoint style is on the wrong side of that drift. Orval still wins today on mocks + Zod from one config — revisit at the next major.

NOTES Two findings point back at the backend

The frontend decision is not frontend-only. These two items are structural, and both belong to Forge, not to itenium-ui.

1
Move capabilities out of the JWT. The README's own worry — that baking capabilities into the token makes it too large — resolves by not doing it. Keep the JWT to sub plus coarse roles, and serve fine-grained capabilities from GET /me, cached in the query layer. That also buys revocation without re-login, which the token never could.
2
Stay on Swashbuckle — for now. .NET 10's built-in OpenAPI generator emits anyOf without a discriminator object for polymorphic types [10], and without a discriminator no generator can emit a TypeScript discriminated union. Swashbuckle was dropped from the default template but is not deprecated [35]. The whole codegen layer rests on this document; a lossy document quietly degrades every layer above it.

TOLERANCES What it costs, honestly

Vendor concentration

Three of the four picks are TanStack. If that family stumbles, three layers stumble together. Accepted — the alternative is a worse stack today for a hedge against a hypothetical.

39.6 kB router

TanStack Router is not free, and it is the heaviest line item on the sheet. It pays for itself by deleting the filter/sort/pagination store — but only if the app really is search-driven. itenium-ui is.

Codegen drift

Orval's per-endpoint hooks sit on the wrong side of the ecosystem's move to composable queryOptions. Open v8 bugs on nullable-only schemas [43]. Hey API is the escape hatch.

In-memory ≠ safe

An in-memory access token is a damage limiter, not a fix. Malicious JS on the page defeats every browser-side storage strategy [11].

Open question · not settled by this run

The IETF's browser-apps BCP reserves its top-ranked architecture — a backend-for-frontend — for exactly the class of app Forge is built for [11]. In the BFF pattern the SPA holds only an httpOnly cookie and tokens never reach the browser at all [44]. So: put a .NET BFF in front of itenium-ui now, while the frontend is still greenfield and the migration is nearly free — or ship the pure SPA and pay for it later?

SHEET INDEX The six angles, as drawn

Each sheet was researched independently, bottom of the stack upward. Every one of them arrived at the same elevation.

Sheet 01 · Codegensurvey
Generating the itenium-ui API client from Forge's OpenAPI document
Orval is the only generator that emits TanStack Query hooks, query keys, MSW mocks and Zod schemas from one config — and generating the client collapses the state-management question to "server cache + a little client state".
30 citations8 min →
Sheet 02 · Server statesurvey
TanStack Query vs RTK Query vs SWR vs route loaders
TanStack Query is the correct server-state layer; its Register.defaultError hook makes RFC 9457 ProblemDetails a first-class typed error with no per-call generics.
32 citations11 min →
Sheet 03 · URL staterecon
The router is a state manager: TanStack Router for itenium-ui
The only option that makes filters/sort/pagination typed URL state instead of Redux slices — and it composes with TanStack Query rather than competing with it.
16 citations3 min →
Sheet 04 · Client statesurvey
Zustand vs Jotai vs RTK vs plain React 19, after the query cache
Once TanStack Query owns server state, the residual client state in a CRUD admin is roughly one persisted preferences store — take Zustand's vanilla createStore in a shared Nx lib and skip the rest.
27 citations8 min →
Sheet 05 · Sessionrecon
Auth & session state on Forge: kill the localStorage plan
Use react-oidc-context with an in-memory access token, put roles/capabilities behind a /me query instead of in the token, and treat a .NET BFF as the target end-state.
11 citations3 min →
Sheet 06 · The questionsurvey
Is Redux still the right answer for a new React 19 app in 2026?
Redux is healthy but no longer the default. Its classic moats — devtools, time-travel, testable reducers — no longer belong to Redux alone.
32 citations11 min →

Two proposed angles — a dedicated pass on testing each candidate, and one on lock-in and migration cost — were offered and left unticked, so they were not researched separately. MSW/Jest coverage appears inside the codegen and client-state sheets instead.

Scout · expedition · 6 sheets · 148 citations Canonical write-up · Atlas