@plitzi/nexus
A lightweight, type-safe state store with a framework-agnostic core and first-class React bindings, built on useSyncExternalStore. You subscribe to dot-notation paths and re-render only when that exact value changes — no selectors, no reducers, no action types. On top of that core it ships scoped stores, time-travel, derived values, an entity adapter, and a middleware pipeline (logger / persist / history). Fully compatible with SSR, React Server Components, Next.js App Router, and Astro (6 LTS & 7) islands.
Entry points
The package root is the framework-agnostic core (zero React). Framework bindings live in their own entries.
@plitzi/nexus | Agnostic core — createStore, imperative get/set/watch, middlewares, async/derived/entities. Zero React. |
@plitzi/nexus/advanced | The opt-in add-ons (async, derived, entities, history, middleware) re-exported in one place, kept off the root so root autocomplete stays focused on the core. |
@plitzi/nexus/react | React bindings — StoreProvider, createStoreHook, useStore, useStoreHistory, useEntity, useAsync, useDerived, … |
@plitzi/nexus/vue | Vue 3 bindings — provideStore, useStore (writable ref), useStoreValue, useEntity, useStoreHistory, … |
@plitzi/nexus/next | Next.js App Router helpers (bindServerAction). |
@plitzi/nexus/rsc | Server Component → Client handoff markers (createServerSnapshot, isServerSnapshot, stripServerFlag). Also re-exported from the root. |
Any other framework works by talking to the root directly (store.subscribe / store.getState) — see the Svelte
example. For Astro, see docs/integrations/astro.md.
npm install @plitzi/nexus
import { createStoreHook } from '@plitzi/nexus/react';
type State = { count: number; user: { name: string } };
const { useStore } = createStoreHook<State>();
function Counter() {
const [count, setCount] = useStore('count');
return <button onClick={() => setCount(n => n + 1)}>{count}</button>;
}
The whole imperative API in three verbs
Outside React, a store speaks three words. get, set and watch are typed against your state and cover the common case — the longer getState / getPath / subscribePath names stay for advanced reads (default values) and back-compat.
import { createStore } from '@plitzi/nexus';
const store = createStore<State>({ count: 0, user: { name: 'Carlos' } });
store.get('user.name');
store.get();
store.set('user.name', 'Ada');
store.set('count', n => n + 1);
store.set('user.name', undefined, { unmount: true });
const off = store.watch('user.name', name => render(name));
Every write takes an optional third argument, SetStateOptions = { canPropagate?; unmount? } — see Removing a path. To freeze paths against writes, see Read-only paths.
What each piece is for
Core — the 90%
createStore | Create a vanilla store (no React needed). Exposes get / set / watch. |
createStoreHook<T>() | Bind your state type once → fully-typed useStore* hooks. |
StoreProvider | Put a store on React context; optionally scope/sync/record it. |
useStore | Subscribe + write a path (or paths). Re-renders on change only. |
Advanced — reach for it when you need it
Also importable from the focused @plitzi/nexus/advanced entry point, which keeps root autocomplete on the Core.
useStoreSetter | A stable setter — write without subscribing/re-rendering. |
useStoreGetter | A stable getter — read current values in callbacks, no re-render. |
useStoreById | Get a named ancestor store's StoreApi by id (reachable across disconnected providers). |
useStoreSync | Mirror an external value (props) into the store. |
createDerived / useDerived | A memoized value computed from store paths (reselect-style). |
createEntityAdapter / createEntityStore | CRUD + selectors for a normalized Record<id, T> map; the store variant adds O(1) per-item updates. |
createAsync / useAsync / useAsyncValue | Race-safe fetch landed on a path; inline UI or Suspense. |
loggerMiddleware / persistMiddleware / historyMiddleware / reduxDevToolsMiddleware / createRecorder | Middlewares: log, persist to storage, record time-travel, connect to Redux DevTools, buffer recent changes for a custom dev-tools panel. |
getStoreHistory / useStoreHistory | Undo / redo / jump-to-snapshot action log (enabled by historyMiddleware). |
setCodegenEnabled | Force on/off the new Function codegen path (auto-detects CSP/SSR). |
createServerSnapshot / isServerSnapshot | Mark server-fetched data for RSC → Client handoff, and check the marker. |
bindServerAction | Optimistic updates + revalidation for Next.js Server Actions. |
Architecture
The package root is the framework-agnostic core; React, Vue, and Next bindings are physically separate
entries that depend on it (never the other way round), so the core ships zero framework code.
src/
index.ts agnostic core barrel (createStore, middlewares, async/derived/entities, rsc)
createStore/ store factory + scope chain / write engine
createStore.ts factory (get/set/watch + getState/setState/subscribe…)
helpers/ writeByPath (codegen), deepMerge, scope reads, PathTrie, Subscribers…
derived/ createDerived (memoized computed values)
entities/ createEntityAdapter (map CRUD) + createEntityStore (reactive O(1) per item)
async/ createAsync (race-safe fetch landed on a path)
middleware/ logger / persist / history / reduxDevTools / cascade (all on subscribeChange)
history/ getStoreHistory (time-travel handle, enabled by historyMiddleware)
rsc/ createServerSnapshot / isServerSnapshot / stripServerFlag
helpers/ getByPath, setByPath, parsePath, shallowEqual, isPathAffected…
types/ StoreTypes.ts — every public type
advanced/ curated barrel re-exporting async + derived + entities + history + middleware
react/ StoreProvider, StoreContext, createStoreHook, hooks/* (useStore, useEntity, useAsync…)
vue/ provideStore/injectStore + composables (useStore, useEntity, useDerived, useAsync…)
next/ bindServerAction (App Router server actions)
One change substrate. Every committed write flows through store.subscribeChange((change) => …) where change is { path, prev, next }. loggerMiddleware, persistMiddleware, and historyMiddleware are all just observers on this channel — and so is any middleware you write. It costs nothing on the hot path when no observer is attached. Writes can also be intercepted before they commit via a middleware's beforeChange to transform or cancel them — see Intercepting writes.
createStore
const store = createStore<MyState>({ count: 0, user: { name: 'Alice' } });
const store = createStore<MyState>((setState, getState) => ({ count: 0 }));
const child = createStore<MyState>({ record }, { parent: rootStore });
const store = createStore<MyState>({ count: 0 }, {
middlewares: [persistMiddleware({ key: 'app' }), historyMiddleware(), loggerMiddleware()]
});
const store = createStore<MyState>({ count: 0 }, { id: 'root' });
const store = createStore<MyState>({ count: 0 }, { readOnly: ['config.theme'] });
StoreApi exposes getState, getPath, setState, subscribe, subscribePath, subscribeChange,
destroy?(), and an optional id. Call destroy() to detach a scoped store from its parent and clear its
listeners (prevents leaks for short-lived scopes like list items). StoreProvider calls it automatically on
unmount.
StoreProvider
Wraps children with a store context. Creates a new store by default; pass store to provide an existing one.
<StoreProvider value={{ count: 0 }}>
<App />
</StoreProvider>
<StoreProvider inherit="snapshot" value={{ extra: true }}> {/* copy once at init, isolated */}
<Child />
</StoreProvider>
<StoreProvider inherit="live" value={{ record }}> {/* live scope chain (see below) */}
<Child />
</StoreProvider>
<StoreProvider path="schema.flat" value={flatMap}>
<Child />
</StoreProvider>
<StoreProvider id="root" value={builderState}>
<App />
</StoreProvider>
<StoreProvider name="Form:hero" inherit="live" value={{ record }}>
<Child />
</StoreProvider>
inherit modes: 'snapshot' copies parent keys once at init — isolated thereafter: writes stay
local and parent updates do not propagate (use for draft/diverge editors). 'live' keeps a live
link — inherited keys stay in sync and writes delegate to the owning scope.
name is a human label of where the store comes from, shown by the devtools store picker (see
DevTools integration). Purely cosmetic — stripped from production.
Scoped stores (live scope chain)
A store can have a parent. Reads resolve through the chain and writes target the owning scope. This
lets nested scopes (e.g. a list item providing its own record) shadow shared/global state while still
reading it live — without prop-drilling or N value-contexts.
const root = createStore<S>({ user: { name: 'Alice' }, theme: 'dark' });
const item = createStore<S>({ record }, { parent: root });
Semantics
- Read fall-through (deep-merge) —
getState() deep-merges the chain: own values win, but nested
object branches are merged, not replaced, so a scope can contribute a sub-key without clobbering the
parent's siblings under the same branch.
item.getState().record;
item.getState().user;
const root = createStore<S>({ runtime: { sources: { variables: { a: 1 } } } });
const item = createStore<S>({ runtime: { sources: { record } } }, { parent: root });
item.getState().runtime.sources;
- Own layer vs merged view (
getOwnState) — getState() returns the full merged view (own +
inherited). getOwnState() returns only this scope's own layer — the keys it seeds/writes locally,
without the parent fall-through. For a root store the two are identical; for a scoped store getOwnState()
isolates the scope's contribution (what a devtools inspector shows to tell nested scopes apart). Both are
cached and reference-stable between changes.
const root = createStore<S>({ user, theme: 'dark' });
const item = createStore<S>({ record }, { parent: root });
item.getState();
item.getOwnState();
- Shadowing — a scope's own leaf value hides the parent's at that path (reads and subscriptions);
sibling leaves under a shared object branch are preserved (see deep-merge above).
const item = createStore<S>({ theme: 'light' }, { parent: root });
item.getState().theme;
root.getState().theme;
- Write delegation (recursive to owner / root) —
setState(path) walks up to the nearest scope that
owns the path; if no ancestor owns it, it delegates all the way to the root. So a deeply-nested scope
can write a shared branch (e.g. runtime.sources.*) and it lands at the root without any scope having to
pre-seed that branch.
item.setState('user.name', 'Bob');
item.setState('runtime.sources.x', v);
item.setState('record', next);
- Multi-level subscriptions — consumers of a scope re-render when an inherited key changes in any
ancestor; shadowed keys are ignored. Equality still filters no-op updates.
- Cleanup — call
store.destroy() (or let <StoreProvider inherit="live"> do it on unmount) to detach
from the parent and avoid listener leaks for short-lived scopes.
Via StoreProvider (the common case):
<StoreProvider value={{ user, theme: 'dark' }}> {}
<StoreProvider inherit="live" value={{ record }}> {}
{}
{}
<ItemView />
</StoreProvider>
</StoreProvider>
Named stores & reaching ancestors (id / storeId / useStoreById)
Give a store an id and any descendant can target it — even across a disconnected (inherit-less)
provider that would otherwise shadow it. The id lives in a parallel registry propagated by context
independently of inherit, so it never breaks the scope chain and costs nothing per provider unless used.
<StoreProvider id="root" value={builderState}>
...
<StoreProvider value={{ local: 1 }}> {}
<Leaf />
</StoreProvider>
</StoreProvider>
const [theme, setTheme] = useStore('theme', { storeId: 'root' });
const getUser = useStoreGetter('user', { storeId: 'root' });
const setUser = useStoreSetter('user', { storeId: 'root' });
const rootStore = useStoreById('root');
rootStore.batch(() => { ... });
const itemCount = createDerived(rootStore, ['items'], ([items]) => items.length);
storeId option — available on every hook (useStore, useStoreGetter, useStoreSetter,
useStoreSync). Resolves the nearest ancestor store registered under that id. An explicit store option
still wins over storeId.
useStoreById(id?) — returns the raw StoreApi. With no id, returns the nearest provider's store.
Use it when you need the store object itself (createDerived/createAsync, batch, subscribe); the
options above only read/write through it.
store.id — the identity is also set on the store, handy for logging/devtools.
- Lifecycle — registration is context-scoped: an id stops resolving the moment its provider unmounts.
There is no global registry, so nothing to clean up and no leaks.
- Duplicate ids (dev) — registering an id that shadows an ancestor with the same id logs a warning in
development (stripped in production); the nearer store wins. Sibling subtrees may reuse an id freely.
useStore
Subscribe to store values. Triggers re-render only when the selected value changes.
const [state, setState] = useStore();
const [name, setName] = useStore('user.name');
const [el = {}] = useStore(`schema.flat.${id}` as PathOf<MyState>);
const [val, setVal] = useStore(s => `style.platform.${s.displayMode}` as PathOf<MyState>);
const [upper] = useStore('user.name', { transformer: v => v.toUpperCase() });
const [[name, count], setName, setCount] = useStore(['user.name', 'count']);
const [[name, val], setName, setVal] = useStore([
'user.name',
s => `style.${s.displayMode}` as PathOf<MyState>
]);
const [derived] = useStore(['user.name', 'count'], {
transformer: ([name, count]) => `${name} (${count})`
});
useStore('user.name', { enabled: false });
useStore('user.name', { mode: 'mount' });
useStore('user.name', { store: myStore });
useStore('user.name', { storeId: 'root' });
useStore('user.name', { equalityFn: Object.is });
useStoreSync
Syncs an external value into the store on every render (write-only, no subscription). Returns void.
useStoreSync(undefined, fullState);
useStoreSync('schema', schema);
useStoreSync(['schema', 'style'], [schema, style]);
useStoreSync('schema', schema, { mode: 'mount' });
useStoreSync('schema', schema, { enabled: false });
useStoreSync('schema', schema, { syncStrategy: 'render' });
useStoreGetter
Non-reactive. Returns a stable getter function that reads the current store value at call time (no re-renders).
const get = useStoreGetter();
get();
get('user.name');
get('user.name', 'default');
const getFlat = useStoreGetter('schema.flat');
getFlat();
getFlat(id);
getFlat(id, fallback);
const [getName, getCount] = useStoreGetter(['user.name', 'count']);
getName();
getCount();
const [getFlat, getCount] = useStoreGetter(['schema.flat', s => s.count]);
useStoreSetter
Non-reactive. Returns a stable setter. No re-renders on write.
const setState = useStoreSetter();
setState('user.name', 'Bob');
setState('user.name', prev => prev + '!');
setState(undefined, fullState);
const setFlat = useStoreSetter('schema.flat');
setFlat(id, element);
setFlat(`${id}.attributes`, attrs);
setFlat(undefined, flatObj);
Removing a path (unmount)
Every write takes an optional third argument: SetStateOptions = { canPropagate?; unmount? }. canPropagate: false commits silently (no subscriber wakes); unmount: true deletes the key at path instead of writing undefined, so the path leaves no dead entry — an object key or array slot that would otherwise linger as undefined.
Reach for it whenever you register/unregister things under a dynamic key (a source registry, a per-id cache) and don't want Object.keys / Object.values to see stale undefined holes.
store.set('sources.abc', { id: 'abc', meta });
store.set('sources.abc', undefined, { unmount: true });
Object.hasOwn(store.get('sources'), 'abc');
- Nested paths delete only the leaf and keep siblings, with structural sharing (
a.b removed → a is a new object; untouched subtrees are shared by reference).
- A numeric leaf on an array is spliced out (later indices shift down), never left as a hole.
- Removing an absent key is a no-op — no change is emitted, no subscriber wakes.
- Writing a value back later recreates the path, rebuilding any container the delete had emptied. In a scope chain the re-write delegates to the owning scope just like the original write.
beforeChange interceptors still run (with value: undefined), so a validation or read-only guard can veto the removal.
unmount is ignored for a whole-state write (set(undefined, next)).
Read-only paths
Pass readOnly to createStore to freeze one or more paths. A write to a read-only path — or to an ancestor or descendant of one — throws in development (surfacing the bug at its source) and is silently dropped in production (so it never crashes a render).
const store = createStore<State>(initial, { readOnly: ['config.theme', 'license'] });
store.set('config.theme', 'dark');
store.set('config', { theme: 'x' });
store.set('config.theme.shade', 'x');
store.set('config.other', 'ok');
- Prefix-safe matching:
readOnly: ['config'] blocks config and config.* but never configuration; ['a.b'] never blocks a.bc.
- The updater form throws before the updater runs; an
unmount of a protected path is blocked too.
- A whole-state replace (
set(undefined, next)) is checked per read-only path by reference — blocked if the value at that path changes identity (rebuilding an equal-but-new object still counts as a change).
- Enforcement is per scope, on that scope's own writes. A child that delegates a write up is rejected by the parent's
readOnly; a key the child owns locally is not governed by an ancestor's readOnly.
- The check is skipped entirely when
readOnly is omitted — zero cost by default.
createStoreHook
Factory that binds TState at the module level, giving fully-typed hooks without repeating the generic at every call site.
const { useStore, useStoreSync, useStoreGetter, useStoreSetter } = createStoreHook<MyState>();
createDerived / useDerived
What it's for: a value computed from store paths — totals, filtered lists, formatted strings. It computes once, memoizes, and only wakes subscribers when the result changes (a dependency edit that doesn't affect the output costs nothing downstream). Think reselect / Jotai derived atoms / MobX computed, shared across every consumer instead of recomputed per component.
import { createDerived, useDerived } from '@plitzi/nexus';
const total = createDerived(
store,
['items'],
([items]) => Object.values(items).reduce((s, i) => s + i.qty * i.price, 0)
);
total.get();
const off = total.subscribe(() => render());
total.destroy();
function CartTotal() {
const value = useDerived(total);
return <span>${value}</span>;
}
const ids = createDerived(store, ['items'], ([m]) => Object.keys(m), {
equalityFn: (a, b) => a.length === b.length && a.every((x, i) => x === b[i])
});
Use createDerived for a value shared across components; reach for useStore('path', { transformer }) when the transform is local to one component.
createEntityAdapter
What it's for: the boilerplate around a normalized Record<id, entity> map. Write ops return immutable updaters you hand straight to setState; selectors read a map. (It doesn't change the cost of an immutable map write — see Performance — it just removes the hand-rolled spread/merge.)
import { createEntityAdapter } from '@plitzi/nexus';
type Todo = { id: string; text: string; done: boolean };
const todos = createEntityAdapter<Todo>();
store.setState('todos', todos.addMany([a, b]));
store.setState('todos', todos.updateOne({ id: 'a', changes: { done: true } }));
store.setState('todos', todos.upsertOne(c));
store.setState('todos', todos.removeOne('b'));
const map = store.getPath('todos');
todos.selectAll(map);
todos.selectById(map, 'a');
todos.selectTotal(map);
createEntityAdapter<Row>({ selectId: r => r.key, sortComparer: (a, b) => a.name.localeCompare(b.name) });
Ops: addOne/addMany (ignore existing), setOne/setMany/setAll (replace), updateOne/updateMany (merge changes), upsertOne/upsertMany (add or merge), removeOne/removeMany/removeAll. Each returns the same map reference when nothing changed, so the store skips the write.
createEntityStore (reactive, O(1) per item)
What it's for: the same normalized collection, but as a standalone reactive store that notifies per item. Where createEntityAdapter lives inside a StoreApi path (one map, one subscription — every consumer wakes when any item changes), createEntityStore lets a row subscribe to just its own id, so editing one item re-renders only that row, not the list. Use it for large lists / tables where the adapter's whole-map churn shows up.
import { createEntityStore } from '@plitzi/nexus';
const todos = createEntityStore<Todo>({ selectId: t => t.id });
todos.setMany([a, b, c]);
todos.updateOne('a', { done: true });
todos.removeOne('b');
todos.getOne('a');
todos.getIds();
todos.getAll();
const off = todos.subscribeOne('a', render);
React — subscribe at the granularity you actually render at:
import { useEntity, useEntityIds, useEntityOne } from '@plitzi/nexus/react';
function List() {
const ids = useEntityIds(todos);
return ids.map(id => <Row key={id} id={id} />);
}
function Row({ id }: { id: string }) {
const todo = useEntityOne(todos, id);
return <li>{todo?.text}</li>;
}
createAsync / useAsync (race-safe fetch)
What it's for: a fetch whose result lands on a store path, with status tracking and last-write-wins race safety, shared across every consumer. Read it inline (loading / error UI) with useAsync, or unwrap the value (Suspense) with useAsyncValue.
import { createAsync } from '@plitzi/nexus';
const user = createAsync(store, 'user', async (id: string) => fetchUser(id));
user.run('42');
user.get();
import { useAsync, useAsyncValue } from '@plitzi/nexus/react';
function Profile() {
const { status, data, error } = useAsync(user);
if (status === 'pending') { return <Spinner />; }
return <span>{data?.name}</span>;
}
function ProfileSuspense() {
const data = useAsyncValue(user);
return <span>{data.name}</span>;
}
Middleware (loggerMiddleware / persistMiddleware / historyMiddleware)
What it's for: cross-cutting logic on every write, centralized in one place. A middleware is (api) => { beforeChange?, onChange? } | void; its setup body runs once after creation (so it can hydrate). It can do two things: intercept a write before it commits (beforeChange) and observe a write after it commits (onChange, the { path, prev, next } change). logger, persist, and history are built-in observers; write your own the same way. Middlewares are per-store — in a scope chain, attach them where the writes you care about commit (shared keys delegate to the owning scope, so the owning scope's interceptors run).
import { createStore, loggerMiddleware, persistMiddleware, historyMiddleware } from '@plitzi/nexus';
const store = createStore<State>(initial, {
middlewares: [
persistMiddleware({ key: 'app' }),
historyMiddleware(),
loggerMiddleware()
]
});
const analytics: StoreMiddleware<State> = api => ({
onChange: ({ path, prev, next }) => track('store.change', { path })
});
const off = store.subscribeChange(({ path, prev, next }) => {});
cascade — share a middleware with nested scopes
Middlewares are per-store, so a nested StoreProvider (or scoped createStore) doesn't inherit the parent's
middlewares by default. Wrap one in cascade() to have every descending store get its own instance of it —
set a logger or analytics sink once at the root instead of repeating it in each child.
import { createStore, cascade, loggerMiddleware } from '@plitzi/nexus';
const root = createStore<State>(initial, {
middlewares: [cascade(loggerMiddleware())]
});
Storage- or recorder-bound middlewares (persistMiddleware, historyMiddleware) are deliberately not
cascaded — they're per-store by nature (one storage key, one recorder). Only mark them if you really mean it.
Intercepting writes (beforeChange)
onChange only observes committed changes — it can't stop or rewrite them. A middleware's beforeChange runs before the write commits and can transform the value, or cancel the write entirely by returning CANCEL. Returning undefined lets the value through unchanged. It's just another middleware, so you write your own the same way you'd write a logger.
import { createStore, CANCEL } from '@plitzi/nexus';
const guard: StoreMiddleware<State> = () => ({
beforeChange: ({ path, value, prev }) => {
if (path === 'role' && !isAdmin) {
return CANCEL;
}
if (path === 'ui.size') {
return Math.min(value as number, 100);
}
return undefined;
}
});
const store = createStore<State>(initial, { middlewares: [guard] });
beforeChange receives a WriteContext: { path, value, prev } — the changed path (undefined for a whole-state write, where value is the full next state), the resolved value about to be written (function setters are already applied, so you see the concrete value), and the current prev at that path.
When several middlewares declare a beforeChange, they run in middleware order, each seeing the previous one's result — so an earlier one can transform a value and a later one can still cancel it. Like every middleware, they run on the store that commits the write (in a scope chain, a write delegated to the owning scope runs that scope's interceptors).
persistMiddleware
Mirrors the store to a key/value storage and rehydrates on creation.
persistMiddleware<State>({
key: 'app',
storage,
partialize: s => ({ user: s.user }),
version: 2,
migrate: (old, v) => ({ user: old }),
merge: (persisted, current) => ({ ...current, ...persisted }),
debounce: 200
});
loggerMiddleware
loggerMiddleware<State>({
filter: change => change.path !== 'mouse',
sink: change => console.log(change.path, change.next)
});
reduxDevToolsMiddleware
Mirrors the store to the Redux DevTools browser extension: every committed change is sent as an action (labelled by the changed path) and time-travel from the DevTools UI (jump / rollback) is written back into the store. It's a no-op when the extension isn't installed (production, SSR, browsers without it), so it's safe to leave wired in.
import { createStore, reduxDevToolsMiddleware } from '@plitzi/nexus';
const store = createStore<State>(initial, {
middlewares: [reduxDevToolsMiddleware({ name: 'my-app' })]
});
store.setState('count', 1);
store.setState('user.name', 'Bob');
reduxDevToolsMiddleware<State>({
name: 'my-app',
action: change => `set:${change.path}`
});
Intended for the root store; like persist/history it's per-store and not cascaded.
createRecorder
A ready-to-use change recorder for building your own dev-tools UI — a bounded, subscribable log of committed writes ({ seq, time, path, prevValue, nextValue }). Unlike loggerMiddleware (fire-and-forget to a sink) it retains recent history and lets a UI read/subscribe; unlike reduxDevToolsMiddleware it needs no browser extension. The getEntries snapshot is reference-stable between records, so it drops straight into useSyncExternalStore.
import { createStore, createRecorder } from '@plitzi/nexus';
import { useSyncExternalStore } from 'react';
const recorder = createRecorder<State>({ max: 100 });
const store = createStore<State>(initial, { middlewares: [recorder.middleware] });
function ChangesPanel() {
const entries = useSyncExternalStore(recorder.subscribe, recorder.getEntries);
return entries.map(e => `${e.path}: ${JSON.stringify(e.prevValue)} → ${JSON.stringify(e.nextValue)}`);
}
entriesSince(seq) / lastSeq() correlate writes with an external timeline (e.g. "which writes happened since the last render commit"); record(change) feeds it directly so one shared recorder can collect from several stores. Per-store, not cascaded.
Time-travel (historyMiddleware)
Records an action log you can undo / redo / jump through. Enable it by adding historyMiddleware() to the store; read it reactively with useStoreHistory, or imperatively with getStoreHistory(store) (which returns the handle the middleware registered, or undefined when it isn't enabled). Without the middleware, useStoreHistory returns an empty, no-op view.
import { useStoreHistory } from '@plitzi/nexus';
function HistoryPanel() {
const { entries, index, canUndo, canRedo, undo, redo, travelTo } = useStoreHistory<State>();
return (
<>
<button disabled={!canUndo} onClick={undo}>Undo</button>
<button disabled={!canRedo} onClick={redo}>Redo</button>
{entries.map((entry, i) => (
<button key={i} onClick={() => travelTo(i)} data-active={i === index}>
{entry.path} = {String(entry.value)}
</button>
))}
</>
);
}
<StoreProvider history> (or history="schema" to scope it to a subtree) starts recording from mount.
DevTools integration
A dev-only global registry lets a devtools panel enumerate and inspect every store — including scoped stores mounted below the panel, where React context can't reach them. Everything here is stripped from production builds (guarded by isDev), so it carries no runtime cost when shipped.
Every StoreProvider registers its store on mount and removes it on unmount. Read the registry with useSyncExternalStore:
import { subscribeDevStores, getDevStoresSnapshot } from '@plitzi/nexus';
import type { DevStoreEntry } from '@plitzi/nexus';
const useDevStores = (): ReadonlyArray<DevStoreEntry> =>
useSyncExternalStore(subscribeDevStores, getDevStoresSnapshot, getDevStoresSnapshot);
Each DevStoreEntry is { uid, store, scopeId?, name? }:
uid — a stable id for selection in a picker.
store — the StoreApi; inspect it with getState() (merged view) or getOwnState() (this scope's own layer — see Scoped stores).
name — the <StoreProvider name> label, e.g. "Form:hero" — a human hint of where the store comes from.
scopeId — an app-defined grouping tag (see DevStoreScopeContext below), e.g. the app/SDK instance the store belongs to, so a panel can group stores by origin.
DevStoreScopeContext tags every store registered beneath it with a scopeId. Wrap a subtree to attribute its stores to one origin (a panel then groups by it):
import { DevStoreScopeContext } from '@plitzi/nexus/react';
<DevStoreScopeContext value={instanceId}>
<App /> {/* every StoreProvider below registers with scopeId = instanceId */}
</DevStoreScopeContext>
Combine the three — name (where it comes from), getOwnState() (what it uniquely holds) and scopeId (which app owns it) — to build a store picker that tells otherwise-identical nested scopes apart. This is exactly what the Plitzi sdk-dev-tools Store tab does.
CSP & new Function
By default @plitzi/nexus uses a compiled codegen path (new Function) for structural-sharing writes, which is orders of magnitude faster than the recursive fallback for deep paths. When a strict Content Security Policy blocks new Function, the store auto-detects the error at first use and falls back to a recursive writer with identical behaviour — you don't need to do anything for CSP environments to work.
If you want to avoid even the one-time probe, force the recursive fallback directly:
import { setCodegenEnabled } from '@plitzi/nexus';
setCodegenEnabled(false);
undefined (default) | Auto-detect: probe new Function once; cache result. |
false | Bypass probe entirely — recursive writer only. Safe for strict CSP. |
true | Force codegen even if probe fails (testing only). |
Call setCodegenEnabled(undefined) to restore auto-detection.
Server Components & SSR
Nexus supports React Server Components (App Router) and server-side rendering (Pages Router) with no additional setup.
Auto-detected SSR safety
When a store is created on the server (typeof window === 'undefined'), Nexus automatically:
- Disables codegen —
new Function is never called, so a strict Content-Security-Policy won't break SSR. Falls back to the recursive writer with identical behaviour.
- Disables localStorage —
persistMiddleware falls back to a no-op storage so getItem/setItem never throw.
- Defers hydration —
StoreProvider runs persistMiddleware hydration in a useEffect after React hydration, preventing hydration mismatches.
You don't need to configure anything for SSR to work.
Server Components → Client data handoff
Wrap server-fetched data with createServerSnapshot to mark it as coming from the server:
import { StoreProvider } from '@plitzi/nexus';
import { createServerSnapshot } from '@plitzi/nexus/rsc';
export default async function Page() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return (
<StoreProvider value={createServerSnapshot({ posts })}>
<ClientContent />
</StoreProvider>
);
}
'use client';
const { useStore } = createStoreHook<{ posts: Post[] }>();
function ClientContent() {
const [posts] = useStore('posts');
}
The SSR flag is a non-enumerable Symbol — invisible in spread/JSON. Using createServerSnapshot is optional; plain objects work too.
rsc subpath exports
createServerSnapshot | Mark data as server-fetched (type-level marker) |
isServerSnapshot | Check if a value has the SSR flag |
stripServerFlag | Remove the SSR flag from a value |
Import from @plitzi/nexus/rsc or from the main entry.
App Router — full pattern
'use client';
import { StoreProvider, createServerSnapshot } from '@plitzi/nexus';
import type { ReactNode } from 'react';
type AppState = {
user: { name: string; email: string } | null;
theme: 'light' | 'dark';
posts: Array<{ id: string; title: string }>;
};
export function AppProviders({
children,
initialState
}: {
children: ReactNode;
initialState: AppState;
}) {
return (
<StoreProvider value={createServerSnapshot(initialState)}>
{children}
</StoreProvider>
);
}
import { AppProviders } from './providers';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const user = await getServerSession();
return (
<html>
<body>
<AppProviders initialState={{ user, theme: 'light', posts: [] }}>
{children}
</AppProviders>
</body>
</html>
);
}
export default async function DashboardPage() {
const posts = await db.posts.findMany();
return <DashboardClient posts={posts} />;
}
'use client';
import { useStoreSync, createStoreHook } from '@plitzi/nexus';
type State = { posts: Array<{ id: string; title: string }> };
const { useStore } = createStoreHook<State>();
export function DashboardClient({ posts }: { posts: State['posts'] }) {
useStoreSync('posts', posts, { mode: 'mount' });
const [localPosts] = useStore('posts');
return (
<div>
{localPosts.map(post => <div key={post.id}>{post.title}</div>)}
</div>
);
}
Pages Router — full pattern
import { createStoreHook, StoreProvider } from '@plitzi/nexus';
import type { InferGetServerSidePropsType } from 'next';
type State = { count: number; user: { name: string } };
export const getServerSideProps = async () => {
const user = await api.getUser();
return { props: { user } };
};
export default function DashboardPage({
user
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
return (
<StoreProvider value={{ user, count: 0 }}>
<Dashboard />
</StoreProvider>
);
}
'use client';
const { useStore } = createStoreHook<State>();
function Dashboard() {
const [user] = useStore('user');
const [count, setCount] = useStore('count');
return <div>...</div>;
}
Server Actions (@plitzi/nexus/next)
The @plitzi/nexus/next subpath exports a bindServerAction helper for optimistic updates
with automatic revalidation:
'use client';
import { useStore } from '@plitzi/nexus';
import { bindServerAction } from '@plitzi/nexus/next';
function Likes({ store, updateLikes }: { store: StoreApi<State>; updateLikes: ServerAction }) {
const [likes, setLikes] = useStore('likes');
const syncLikes = bindServerAction(store, 'likes', updateLikes, { revalidatePath: '/dashboard' });
return <button onClick={() => syncLikes(n => n + 1)}>{likes}</button>;
}
bindServerAction | Optimistic update + rollback + revalidation for Next.js Server Actions |
ServerActionResult | Return type of bindServerAction |
This module dynamically imports from next/cache — it only works within Next.js App Router.
It is not a peer dependency, just a compatibility layer.
Vue 3 (@plitzi/nexus/vue)
The same agnostic core, bound to Vue's reactivity via provide/inject and composables that return refs.
Provide a store at a parent, then bind a path two-ways with useStore (write through ref.value or v-model),
or read-only with useStoreValue.
<!-- App.vue -->
<script setup lang="ts">
import { provideStore } from '@plitzi/nexus/vue';
type State = { count: number; user: { name: string } };
provideStore<State>({ count: 0, user: { name: 'Ada' } });
</script>
<!-- Counter.vue -->
<script setup lang="ts">
import { useStore, useStoreValue } from '@plitzi/nexus/vue';
const count = useStore<State, 'count'>('count'); // WritableComputedRef — re-renders only on change
const name = useStoreValue<State, 'user.name'>('user.name'); // read-only ref
</script>
<template>
<button @click="count++">{{ name }}: {{ count }}</button>
</template>
createStoreComposable<State>() mirrors React's createStoreHook — it returns useStore / useStoreValue /
useStoreState already bound to State. Vue also ships useEntity, useDerived, useAsync, and
useStoreHistory composables matching their React counterparts.
Performance
Notification is O(depth) — a few Map lookups for the changed path's prefixes — regardless of how many subscribers exist, so a million watchers cost the same as one for an unrelated change. The write copies the containers on the changed path (immutable structural sharing, the same cost Redux / Zustand / Jotai pay), so model state as a tree / normalized map to keep each write touching a small path. Single-segment writes mutate the live working copy in place (O(1)); snapshots from getState() are always distinct clones, so they stay immutable for React.
Types
All types live in src/types/StoreTypes.ts:
PathOf<T> | Union of all valid dot-paths in T |
PathValue<T, P> | Value type at path P in T |
PathOrFn<T> | PathOf<T> or a function (state: T) => PathOf<T> |
PathValues<T, Paths> | Tuple of values for an array of paths |
PathSetter<T, P> | Setter function for a single path |
PathSetters<T, Paths> | Tuple of setters for an array of paths |
MultiPathReturn<T, Paths> | [values, ...setters] tuple |
StoreApi<T> | { getState, getOwnState, getPath, setState, subscribe, subscribePath, subscribeChange, destroy? } |
StoreChange<T> | { path, prev, next } — payload of subscribeChange / middleware onChange |
DevStoreEntry | { uid, store, scopeId?, name? } — a store registered in the dev-only registry (see DevTools integration) |
StoreMiddleware<T> | (api) => { beforeChange?, onChange? } | void — a middleware factory |
WriteContext<T> | { path, value, prev } — payload of a beforeChange interceptor |
WriteInterceptor<T> | (ctx: WriteContext<T>) => unknown — return a value, CANCEL, or undefined |
CANCEL | Sentinel an interceptor returns to abort a write |
Derived<R> | { get, subscribe, destroy } — a createDerived handle |
EntityAdapter<T> | CRUD updaters + selectors from createEntityAdapter |
EntityUpdater<T> | (map) => map — an immutable entity-map update for setState |
SetState<T> | Full setState overload signature |
UseStoreOptions | Options for useStore (mode, enabled, equalityFn, transformer, store, storeId) |
UseStoreMultiOptions | Options for multi-path useStore |
UseStoreSyncOptions | Options for useStoreSync (mode, enabled, syncStrategy) |
UseStoreSyncMultiOptions | Options for multi-path useStoreSync |
UseStoreGetterOptions | Options for useStoreGetter |
UseStoreSetterOptions | Options for useStoreSetter |