@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
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.
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
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 changeReducer store
Redux-shaped store: state + reducer + dispatch. Reducers cannot dispatch inside themselves (throws) — defer with queueMicrotask if you need a follow-up action.
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
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.
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
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
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
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 subscriptionSelectors
Memoized derivation. createSelector uses reference equality across the input selectors; createSelectorWith takes a custom equality fn (e.g. shallowEqual for object-shaped derivations).
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
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.
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.
const store = getStore('cart', initial, reducer);
const state = store.getState();
template = template.replace('</head>', `<script id="__CART__">window.__CART__=${safeJsonForScript(state)}</script></head>`);const initial = (window as any).__CART__ ?? defaultInitial;
const store = getStore('cart', initial, reducer); // first call wins — server state takes effectFederation 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
// @internal — useful in test setup
_resetStore(key?: string): void;
_resetSimpleStore(key?: string): void;
import { _resetStore } from '@jorvel/state';
beforeEach(() => { _resetStore(); });