← Default view
Itenium.Forge · itenium-ui · survey · 27 sources · 8 min

Six libraries
are fighting over
three fields

Once TanStack Query owns server state, the residual global client state in a CRUD admin is theme, sidebar and column visibility. Scope the job first — then read the table.

React 19Nx 21ViteJest, no render2 apps, 1 store
01

The cut — who is actually global

7 candidates · 3 survive

Seven things get called “client state” in a React admin. Four of them are not global at all — they have a route's lifecycle, or a library already owns them, or they were server state wearing a disguise. Run the cut before the shootout. A library evaluation that skips this step buys a state manager for work the state manager never had.

Verdict
Candidate
Where it actually belongs
In
Theme light / dark / system
persisted store Cross-app and must survive a reload — that is exactly what persist + localStorage is for. [14]
In
Sidebar collapse app shell + a few pages
same store TanStack's own canonical example of what's left over. [1]
In
Column visibility keyed by table id
same store A per-user preference that survives reload. ⚠ Column filter / sort / page is not a preference — it must be linkable. That goes in the URL via nuqs ⭐ 11k [23], not the store.
Cut
Multi-step wizard draft lifecycle = the wizard's, not the app's
useReducer, in the route Put it in the global store and you own a reset-on-unmount bug forever. Add persist only if a half-finished draft must survive reload — and give it its own store name.
Cut
Toasts sonner / shadcn useToast
the toast library Already an external store behind the scenes. A second one is duplication.
Cut
Auth session server state in disguise
query cache · useQuery(['me']) Keycloak/OpenIddict issues it, the server invalidates it. The token lives in the OIDC client. ⚠ One exception: the isAuthenticated boolean the router guards on can live in the store.
Cut
Feature flags never mutated by the UI
query cache · or import.meta.env Server-owned (fetched) or a build-time constant in Vite. Either way, not app state.
3fields left
1middleware (persist)
0providers required
“The truly globally accessible client state that is left over after migrating all of your async code to TanStack Query is usually very tiny.” TanStack Query docs — “Does this replace client state?” [1]

React 19 shrinks it again: useActionState and useOptimistic absorb pending / error / rollback state for form-shaped mutations — which in a CRUD admin is most mutations. [9]

02

The league table

Job spec: 3 fields · persist · testable headless

Now — and only now — the shootout. Every row is scored against a job that is three fields wide, so bundle weight and boilerplate are real costs while devtools depth is mostly theatre. Bars are drawn against the heaviest entrant (XState, ~15 KB).

Pos Library Verdict for itenium-ui Bundle gz Store setup Jest w/o React localStorage
THE PICK
1
Zustand v5.0 · ⭐ 59k [4]
createStore from zustand/vanilla is a plain object — no React import, no Provider, nothing to leak across Nx boundaries. Redux DevTools via the devtools middleware, so you keep time-travel without the 12 KB.
1.2 KBlightest that persists
~12lines, no Provider [25]
YescreateStore().getState() — zero React [12]
Built inpersist + partialize + migrate
2
Jotai v2.20 · ⭐ 21k [5]
Best TS inference of the group and an automatic per-atom dependency graph. The credible runner-up — take it if you ever find yourself fighting selector granularity. Context-first by its own docs [11]: provider-less works, but test isolation pulls you back to <Provider> [27].
3.5 KB3× Zustand
~1line per atom
YescreateStore() + store.get/set [20]
Built inatomWithStorage
3
Valtio v2.3 · ⭐ 10k [7]
Fine engineering — useSnapshot is useSyncExternalStore + proxy-compare, so render tracking is automatic [26]. But mutable proxy semantics fight the immutable habits of a .NET architect, and it buys nothing Zustand doesn't.
3.2 KBproxy runtime
~5lines, mutable
Yesthe proxy is a plain object
Manualhand-rolled subscribe
4
Redux Toolkit v2.12 · ⭐ 11k [6]
Best-in-class time-travel devtools — for three booleans you will never open them. The store is only reachable through React context, so libs/state becomes a React lib both apps must wrap themselves in [10]. You test reducers, not the store.
12 KB10× the winner
~28lines + Provider [25]
Partlyreducers are pure — the wiring still needs a Provider
3rd partyredux-persist
5
XState v5.32 · ⭐ 30k [8]
Not a competitor — a scalpel. Excellent for one genuinely gnarly wizard via @xstate/store [21], which drops into libs/ cleanly. Adopting statecharts as the app's state layer for three booleans is not KISS.
~15 KBheaviest entrant
machinedefinition per concern
Yesmachines run headless
Manualno built-in persistence
6
Plain React 19 useState · useReducer · Context
Wins the bundle column outright and still loses the tie. Context re-renders every consumer on value change [19] — fine for a theme that flips twice a session, wrong for anything a table touches. And you cannot test your state logic without React at all.
0 KBalready in the app
0nothing to install
Noneeds renderHook + a wrapper
Nohand-rolled useEffect

Read the last row twice. Plain React posts the best bundle score on the board and still isn't the pick — the two columns it fails are the two the job actually has. That is the whole argument: persistence, plus not re-rendering the world from a theme Context. It's a 1.2 KB problem.

Weekly npm downloads — dedicated client-state libraries

State of React 2025 calls Zustand the category leader on satisfaction and says it is climbing fast [2]. RTK's volume is largely legacy projects with no reason to migrate [24].

Zustand 22.6M
Redux Toolkit 11.4M
Jotai 2.9M
Valtio 1.2M

Figures: gzip bundle sizes and weekly downloads, 2026 [3]. Recoil was archived by Meta in early 2025 — it isn't on the sheet for a reason.

03

Where the store lives — the Nx tiebreak

The axis every generic blog post misses

The table above narrows it. The monorepo decides it. Zustand's vanilla createStore produces a plain object with getState / setState / subscribe and no React import at all [10] — which means the shared lib carries no React peer dependency and no context to leak across the two apps.

libs/state — tagged type:util, scope:shared

  • Theme, sidebar. Identical semantics in both apps.
  • No React peer dep, no Provider → no ordering constraint between the app shell and the lib. RTK forces the opposite: the store is only reachable through context.
  • Sits at the bottom of the tag graph: onlyDependOnLibsWithTags: ["type:util"].

apps/file-management — owned locally

  • Column visibility for that app's grids. Split by ownership, not convenience.
  • A store is cheap. Two small stores in the right libs beat one god-store that forces both apps to rebuild on every change.
  • Nx's affected graph rewards the split.
The realistic failure is libs/state importing a feature lib for a type while that feature lib imports the store. The @nx/enforce-module-boundaries lint rule fails the PR before it lands [17]. ⚠ It is project-level only, not folder-level — so a fat libs/state holding both apps' state is a boundary you cannot lint inside [18].
04

The proof — the store under Jest, no render

No Provider · no renderHook · no act

The hard requirement, satisfied. A vanilla store in a lib, and a test that never mounts a component.

libs/state/src/lib/ui-preferences.store.ts
import { useStore } from 'zustand';
import { createStore } from 'zustand/vanilla';
import { createJSONStorage, persist } from 'zustand/middleware';

export type Theme = 'light' | 'dark' | 'system';

export interface UiPreferences {
  theme: Theme;
  sidebarCollapsed: boolean;
  hiddenColumns: Record<string, string[]>;
  setTheme: (theme: Theme) => void;
  toggleSidebar: () => void;
  toggleColumn: (table: string, column: string) => void;
}

export const uiPreferencesStore = createStore<UiPreferences>()(
  persist(
    (set) => ({
      theme: 'system',
      sidebarCollapsed: false,
      hiddenColumns: {},
      setTheme: (theme) => set({ theme }),
      toggleSidebar: () => set((s) => ({
        sidebarCollapsed: !s.sidebarCollapsed,
      })),
      /* toggleColumn: table-keyed, see canonical */
    }),
    {
      name: 'itenium.ui.preferences',
      version: 1,
      storage: createJSONStorage(() => localStorage),
      partialize: ({ theme, sidebarCollapsed, hiddenColumns }) =>
        ({ theme, sidebarCollapsed, hiddenColumns }),
    },
  ),
);

export const useUiPreferences = <T,>(
  selector: (s: UiPreferences) => T,
): T => useStore(uiPreferencesStore, selector);
libs/state/src/lib/ui-preferences.store.spec.ts
import { uiPreferencesStore } from './ui-preferences.store';

const initialState = uiPreferencesStore.getInitialState();

beforeEach(() =>
  uiPreferencesStore.setState(initialState, true));

describe('uiPreferencesStore', () => {
  it('toggles a column off, then back on', () => {
    const { toggleColumn } = uiPreferencesStore.getState();

    toggleColumn('users', 'email');
    expect(uiPreferencesStore.getState()
      .hiddenColumns['users']).toEqual(['email']);

    toggleColumn('users', 'email');
    expect(uiPreferencesStore.getState()
      .hiddenColumns['users']).toEqual([]);
  });

  it('notifies subscribers on theme change', () => {
    const listener = jest.fn();
    uiPreferencesStore.subscribe(listener);

    uiPreferencesStore.getState().setTheme('dark');

    expect(listener).toHaveBeenCalledTimes(1);
    expect(uiPreferencesStore.getState().theme)
      .toBe('dark');
  });
});

The reset idiom

  • getInitialState() + setState(next, true) — the true means replace, not merge.
  • Zustand's own Jest guide scales this to every store at once with a __mocks__/zustand.ts that collects reset functions and fires them in afterEach [12] — adopt it the moment you have more than two stores.

The one environment constraint

  • persist with createJSONStorage(() => localStorage) needs a DOM. Nx's React Jest preset already runs jsdom.
  • Move libs/state to a node test environment and store construction itself throws.
05

Cards shown

1 red · 3 yellow
Red card · sending-off offence
Object selectors in Zustand v5

useStore(store, s => ({ a: s.a, b: s.b })) returns a fresh reference each render → useSyncExternalStore sees a change → Maximum update depth exceeded [15]. It is the single most common v5 crash [16]. Select primitives one at a time, or wrap in useShallow. Add the ESLint rule and it never bites twice.

Yellow · persistent offending
The wizard draft in the global store

It has the wizard's lifecycle, not the app's. useReducer, co-located with the route. If a half-finished draft must survive a reload, give it its own store with its own name — then clearing it is one .clearStorage() call.

Yellow · dissent
“Context is free”

Any change to a context value re-renders every consumer in the subtree [19]. Fine for a theme that changes twice a session; wrong for anything a table touches. That is the entire reason external stores exist.

Yellow · playing out of position
XState as the app's state layer

Statecharts for three booleans is not KISS. Reach for @xstate/store or a machine for the one wizard that earns it [21] — it's framework-agnostic and drops into libs/ cleanly.

FT

Full time — the stack for itenium-ui

Four layers, one store
Server state
TanStack Query

Owns everything the Forge API returns — including /me and feature flags.

Client state
Zustand

One libs/state lib, vanilla createStore, persist, tagged type:util, lint-fenced at the bottom of the dep graph. devtools middleware in dev only — it speaks Redux DevTools, so you keep time-travel without the 12 KB.

URL state
nuqs

⭐ 11k [23] — table filter / sort / page, when it needs to be linkable. It is state you would otherwise wrongly put in the store.

Component state
React 19 built-ins

useState for modals and popovers, useReducer for wizards, useActionState / useOptimistic for CRUD mutations.

You could ship this app with zero state libraries. 34% of React devs do.

The honest reason not to is persistence, plus avoiding a theme Context that re-renders the world [2]. That's a 1.2 KB problem — and Zustand is the 1.2 KB answer.