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.
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.
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.
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.
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].
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].
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].
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].
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:
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].
Four new runtime dependencies plus a codegen step. Three of them ship from one vendor — that is the concentration risk, stated plainly.
| Package | Layer | Gzip | Stars | Owns |
|---|---|---|---|---|
| 01 · codegen | devDep | ⭐ 6.2k | Hooks, query keys, Zod schemas, MSW mocks | |
| 02 · server state | 13.3 kB | ⭐ 50k | Every byte that came from the API | |
| 03 · URL state | 39.6 kB | ⭐ 15k | Filters, sort, page, selection, route guards | |
| 04 · client store | 1.2 kB | ⭐ 59k | Theme, sidebar, column visibility | |
| — · session | in-memory | ⭐ 1.0k | Access token (Context, not a store) |
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.
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.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.The frontend decision is not frontend-only. These two items are structural, and both belong to Forge, not to itenium-ui.
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.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.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.
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.
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.
An in-memory access token is a damage limiter, not a fix. Malicious JS on the page defeats every browser-side storage strategy [11].
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?
Each sheet was researched independently, bottom of the stack upward. Every one of them arrived at the same elevation.
Register.defaultError hook makes RFC 9457 ProblemDetails a first-class typed error with no per-call generics.createStore in a shared Nx lib and skip the rest./me query instead of in the token, and treat a .NET BFF as the target end-state.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.
The eleven cited on this sheet, plus the primary specification documents behind them.