JORVEL

@jorvel/state

Lightweight shared-state primitives for JORVEL micro-frontends. Pins registries to globalThis so host + remote still observe the same store when MF singleton sharing fails. Federation share-scope is the fast path; globalThis is the safety net.

When to pick which

SimpleStore — one value, subscribers, optional equality. Theme, locale, feature flags.
Reducer store — non-trivial state machines, auditable actions, SSR hydration.
Selectors — derive view models without recomputing.

SimpleStore

Single-value store with subscribe + set. Equality check prevents needless re-renders.

ts
getSimpleStore<T>(key: string, initial: T): SimpleStore<T>;

class SimpleStore<T> {
  constructor(initial: T, opts?: { equalityFn?: (a: T, b: T) => boolean });
  get(): T;
  set(next: T): void;                              // notifies if !eq(prev, next)
  subscribe(listener: (v: T) => void): () => void;
  readonly listenerCount: number;
}

Usage

ts
import { getSimpleStore } from '@jorvel/state';

const theme = getSimpleStore<'light' | 'dark'>('theme', 'light');

theme.subscribe((next) => document.documentElement.dataset.theme = next);

theme.set('dark');           // notifies
theme.set('dark');           // skipped — value didn't change

Reducer store

Redux-shaped store: state + reducer + dispatch. Reducers cannot dispatch inside themselves (throws) — defer with queueMicrotask if you need a follow-up action.

ts
type Reducer<S, A> = (state: S, action: A) => S;

createStore<S, A>(initialState: S, reducer: Reducer<S, A>): Store<S, A>;
getStore<S, A>(key: string, initialState: S, reducer: Reducer<S, A>): Store<S, A>;

interface Store<S, A> {
  getState(): S;
  dispatch(action: A): void;                       // throws if called inside the reducer
  subscribe(listener: (s: S) => void): () => void;
  replaceReducer(next: Reducer<S, A>): void;
  replaceState(next: S): void;                     // throws inside a reducer
  readonly listenerCount: number;
}

Usage

ts
import { getStore } from '@jorvel/state';

interface Cart { items: { sku: string; qty: number }[] }
type Action =
  | { type: 'add'; sku: string }
  | { type: 'remove'; sku: string }
  | { type: 'clear' };

const cart = getStore<Cart, Action>('cart', { items: [] }, (state, action) => {
  switch (action.type) {
    case 'add':    return { items: [...state.items, { sku: action.sku, qty: 1 }] };
    case 'remove': return { items: state.items.filter((i) => i.sku !== action.sku) };
    case 'clear':  return { items: [] };
    default:       return state;
  }
});

cart.dispatch({ type: 'add', sku: 'BOOK-42' });
cart.getState().items;       // [{ sku: 'BOOK-42', qty: 1 }]

Middleware

Standard Redux-shape middleware. thunkMiddleware lets you dispatch async functions; loggerMiddleware prints actions + state diffs; persistenceMiddleware serializes the store to localStorage or a custom sink.

ts
createStoreWithMiddleware<S, A>(
  initialState: S,
  reducer: Reducer<S, A>,
  middlewares: Middleware<S, A>[],
): Store<S, A>;

applyMiddleware<S, A>(store: Store<S, A>, ...middlewares: Middleware<S, A>[]): Store<S, A>;

thunkMiddleware<S, A>(): Middleware<S, A | ThunkAction<S, A>>;
loggerMiddleware<S, A>(opts?: LoggerOptions): Middleware<S, A>;
persistenceMiddleware<S, A>(opts: PersistenceMiddlewareOptions<S>): Middleware<S, A>;

type Middleware<S, A> =
  (api: MiddlewareApi<S, A>) => (next: (action: A) => void) => (action: A) => void;

interface MiddlewareApi<S, A> {
  getState(): S;
  dispatch(action: A): void;
}

Thunks

ts
import { createStoreWithMiddleware, thunkMiddleware } from '@jorvel/state';

const cart = createStoreWithMiddleware<Cart, Action>(initial, reducer, [thunkMiddleware()]);

const fetchCart = (): ThunkAction<Cart, Action> => async (dispatch) => {
  const res = await fetch('/api/cart');
  const items = await res.json();
  dispatch({ type: 'replace', items });
};

cart.dispatch(fetchCart());

Persistence

ts
import { persistenceMiddleware } from '@jorvel/state';

const persisted = persistenceMiddleware<Cart, Action>({
  key: 'jorvel:cart',
  storage: typeof localStorage !== 'undefined' ? localStorage : undefined,
  serialize: JSON.stringify,
  deserialize: JSON.parse,
  throttleMs: 100,
  filter: (state) => ({ items: state.items }),     // omit derived fields
});

Atoms

ts
atom<T>(initial: T): WritableAtom<T>;          // { get(); set(next | (prev)=>next); subscribe(cb) }
derivedAtom<Deps, T>(deps: Deps, compute: (values) => T): ReadableAtom<T>;
isWritableAtom<T>(a: ReadableAtom<T>): a is WritableAtom<T>;

// @jorvel/state/react
useAtom<T>(atom: WritableAtom<T>): [T, (next: T | ((prev: T) => T)) => void];
useAtomValue<T>(atom: ReadableAtom<T>): T;       // subscribes
useSetAtom<T>(atom: WritableAtom<T>): (next) => void;  // setter only, no subscription

Selectors

Memoized derivation. createSelector uses reference equality across the input selectors; createSelectorWith takes a custom equality fn (e.g. shallowEqual for object-shaped derivations).

ts
type Selector<S, R> = (state: S) => R;
type EqualityFn<R> = (a: R, b: R) => boolean;

shallowEqual<T extends object>(a: T, b: T): boolean;

createSelector<S, A, R>(...inputs: Selector<S, A>[], result: (...inputs: A[]) => R): Selector<S, R>;

createSelectorWith<S, R>(
  opts: { equalityFn: EqualityFn<R> },
  ...inputs: Selector<S, any>[],
  result: (...inputs: any[]) => R,
): Selector<S, R>;

createStructuredSelector<S, M extends Record<string, Selector<S, any>>>(
  map: M,
): Selector<S, { [K in keyof M]: ReturnType<M[K]> }>;

Examples

ts
import { createSelector, createStructuredSelector } from '@jorvel/state';

const items = (s: Cart) => s.items;
const total = createSelector(items, (xs) => xs.reduce((n, i) => n + i.qty, 0));
const view  = createStructuredSelector({
  count: total,
  items,
});

view(cart.getState());       // { count: 3, items: [...] }

Redux DevTools

connectDevtools(store, opts?) wires the store into the Redux DevTools browser extension. No-op when the extension is missing. Use in dev only; the connection has non-trivial CPU cost.

ts
connectDevtools<S, A>(store: Store<S, A>, opts?: DevtoolsOptions): () => void;

interface DevtoolsOptions {
  name?: string;             // shown in the dropdown
  trace?: boolean;           // capture action stack traces
  maxAge?: number;           // history depth
}

if (process.env.NODE_ENV !== 'production') {
  connectDevtools(cart, { name: 'cart' });
}

SSR hydration

Serialize on the server, hydrate on the client. Pair with safeJsonForScript for the inline script payload.

server
ts
const store = getStore('cart', initial, reducer);
const state = store.getState();
template = template.replace('</head>', `<script id="__CART__">window.__CART__=${safeJsonForScript(state)}</script></head>`);
client
ts
const initial = (window as any).__CART__ ?? defaultInitial;
const store = getStore('cart', initial, reducer);   // first call wins — server state takes effect

Federation singleton

@jorvel/state is declared as a singleton in the generated jorvel.federation.json. Combined with the globalThis registry, two bundles loading the package independently still write to the same store map.

Mismatch warnings

getStore(key, initial, reducer) compares the signature of subsequent calls against the first call. Different signatures log a warning in development — typically signals two bundles disagreeing on the store shape.

Testing helpers

ts
// @internal — useful in test setup
_resetStore(key?: string): void;
_resetSimpleStore(key?: string): void;

import { _resetStore } from '@jorvel/state';

beforeEach(() => { _resetStore(); });