nusm
v1.2.0
Published
Non Uniform State Manager nusm (pronounced "noose em") built on @tanstack/store with optional persisted adapters.
Readme
nusm
Non Uniform State Manager (nusm) > Pronounced noose em (/ˈnuːs əm/)
is a persistence-ready wrapper around
@tanstack/store with adapter-based storage.
Explore the full documentation at mwillbanks.github.io/nusm.
Features
- Same store semantics as @tanstack/store
- Optional persistence via adapters
- Entire-store or slice-based persistence
- Async hydration with deep merge
- Debounced persistence via @tanstack/pacer
- Adapter-defined external events through subscriptions
- Opt-in authenticated browser synchronization for every identified store
- React hooks (via
nusm/react) - Optional TanStack Devtools store inspector (
nusm/devtools)
Install
Use Node.js 20 or newer, or Bun. Pacer's current release requires an ESM runtime. See the dependency upgrade guide for upgrade review and validation procedures.
bun add nusmQuick Start
adapter and persist are optional. This creates an in-memory nusm store:
import { createNusmStore } from "nusm";
const workspaceStore = createNusmStore({ selectedId: null, panelOpen: false });Use the same store API when state needs a different lifetime. Persistence is a per-store choice, not an application-wide mode:
import { createNusmStore, createSessionStorageAdapter } from "nusm";
const sessionStore = createNusmStore(
{ evidence: [], startedAt: Date.now() },
{
storeId: "session",
adapter: createSessionStorageAdapter(),
persist: { strategy: "entire" },
sync: {
validator: (candidate) =>
typeof candidate === "object" &&
candidate !== null &&
Array.isArray((candidate as { evidence?: unknown }).evidence) &&
typeof (candidate as { startedAt?: unknown }).startedAt === "number",
},
},
);
await sessionStore.ready;The in-memory store resets on reload. The session store survives reloads in its current tab, then expires with that tab session. Other stores can independently use local storage, IndexedDB, a custom adapter, or no adapter. Slice persistence can also keep selected fields while the rest of one store remains memory-only.
API
createNusmStore(initialState, options?)
Creates a nusm-backed store.
Omit adapter and persist for ordinary in-memory state. In that mode,
ready resolves immediately, hydration.overall is not_configured, and
storeId is optional. Adding an adapter enables persistence for that store;
when persist is omitted, nusm defaults to entire-store persistence.
import { createNusmStore } from "nusm";
const nusm = createNusmStore(initialState, {
storeId: "settings",
adapter,
persist: {
strategy: "entire",
},
});Return value:
- A @tanstack/store instance extended with
ready,dispose, readonlyisReady, and an immutablehydrationsnapshot.hydration.overalland eachhydration.byKeyentry reportpending,hydrated,discarded,error,cancelled, ornot_configured; read a fresh snapshot after lifecycle events rather than mutating it.
Authenticated synchronization
Broadcast synchronization is disabled by default. Configure an HMAC key and
namespace before creating stores. The key must be at least 32 random bytes or a
non-extractable HMAC-SHA-256 CryptoKey with sign and verify usage:
import {
configureNusm,
createBroadcastChannelSyncSource,
createNusmStore,
} from "nusm";
// Provision the same random key to every authorized tab at runtime.
const keyBytes = Uint8Array.from(
atob(await fetch("/sync-key").then((response) => response.text())),
(character) => character.charCodeAt(0),
);
const key = await crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
const source = createBroadcastChannelSyncSource({
namespace: "my-app",
key,
});
configureNusm({
sync: {
source,
validator: (candidate) => {
// Validate the shared-counter shape before it reaches store memory.
if (
typeof candidate !== "object" ||
candidate === null ||
Array.isArray(candidate)
)
return false;
return typeof (candidate as { count?: unknown }).count === "number";
},
},
});
// A configured global source auto-enables stores with an explicit storeId.
const shared = createNusmStore({ count: 0 }, { storeId: "shared-counter" });
const local = createNusmStore(
{ draft: "" },
{ sync: false, storeId: "local-draft" },
);Sources use an opaque HMAC-derived channel and authenticate every fixed-order
header and SuperJSON payload. They bound state to 256 KiB, envelopes to 512 KiB,
identifiers to 128 UTF-8 bytes, peers to 32, and message identities to 128 per
peer. Invalid traffic receives bounded error reports and temporary quarantine.
Use sync: { source, validator } to override the global source for one store;
the store validator always runs before remote state reaches memory or storage.
Synchronization remains defense-in-depth against a compromised same-origin
application. Never synchronize credentials, tokens, secrets, or sensitive
personal data. Every synchronized or persisted store requires an explicit
storeId; use sync: false for a store-level opt-out.
Breaking changes and upgrades
nusm now targets the current @tanstack/store API directly. The legacy
Derived and Effect compatibility exports have been removed, and
useStore accepts only stores returned by createNusmStore.
See the upgrade guide before upgrading an existing integration.
Persistence strategies
Entire store
persist: {
strategy: "entire";
}Slices
persist: {
strategy: 'slices',
slices: [
{
key: 'todos',
select: (state) => state.todos,
apply: (state, sliceValue) => ({ ...state, todos: sliceValue }),
},
],
}Hydration configuration
When a configured persistence unit is missing, nusm writes the corresponding initial entire-store value or selected slice before ready resolves. A successfully hydrated store therefore has a real adapter baseline; zero pending keys means those initial writes have completed, not that persistence was skipped. Existing, discarded, and failed adapter values retain their normal hydration policy.
persist: {
strategy: 'entire',
hydrate: {
discardPersisted: false,
validate: (persisted) => ({ ok: true, value: persisted }),
merge: ({ initial, persisted }) => ({ ...initial, ...persisted }),
},
}Adapters
Adapters control persistence. They define how nusm reads/writes state and how external changes (for example, another tab) are observed.
Adapter interface
type NusmAdapter = {
name: string;
getItem(key: string): unknown | null | Promise<unknown | null>;
setItem(key: string, value: unknown): void | Promise<void>;
removeItem(key: string): void | Promise<void>;
getAllKeys?(): string[] | Promise<string[]>;
clear?(): void | Promise<void>;
subscribe?(
listener: (event: {
type: "set" | "remove" | "clear";
key?: string;
}) => void,
): () => void;
resolveKey?(params: {
storeId: string;
sliceKey?: string;
kind: "entire" | "slice";
}): string;
pacer?:
| false
| {
wait?: number;
maxWait?: number;
leading?: boolean;
trailing?: boolean;
};
};Notes:
getAllKeysis optional adapter-wide enumeration for callers that need it; devtools snapshots read configured persistence units throughresolveKey.resolveKeylets you control key layout. When omitted, nusm usesnusm:<storeId>:entireandnusm:<storeId>:slice:<sliceKey>.subscribeshould emit adapter-defined external events. nusm always attaches it when provided. Authenticated synchronization independently shares state between browser contexts.- Built-in
localStorageandsessionStorageadapters providesubscribe; persisted stores using them must configure a shape-specificsync.validatoreven when no browser synchronization source is enabled. The validator protects against adapter-originated external values. pacercontrols debouncing of writes. Usefalseto write immediately.
Creating a custom adapter
const memoryAdapter: NusmAdapter = {
name: "memory",
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => store.set(key, value),
removeItem: (key) => store.delete(key),
getAllKeys: () => Array.from(store.keys()),
resolveKey: ({ storeId, kind, sliceKey }) =>
kind === "entire"
? `nusm:${storeId}:entire`
: `nusm:${storeId}:slice:${sliceKey}`,
};Local Storage
import { createLocalStorageAdapter } from "nusm";
const adapter = createLocalStorageAdapter();Options:
storage: aStorage-like implementation (defaults towindow.localStorage).prefix: key prefix (default:nusm).serialize: custom serializer (default:superjson.stringify).deserialize: custom deserializer (default:superjson.parse).pacer: persistence debouncer configuration.
Session Storage
import { createSessionStorageAdapter } from "nusm";
const adapter = createSessionStorageAdapter();Options:
storage: aStorage-like implementation (defaults towindow.sessionStorage).prefix: key prefix (default:nusm).serialize: custom serializer (default:superjson.stringify).deserialize: custom deserializer (default:superjson.parse).pacer: persistence debouncer configuration.
IndexedDB
import { createIndexDbAdapter } from "nusm";
const adapter = createIndexDbAdapter({ dbName: "my-db" });Options:
dbName: database name (default:nusm).storeName: object store name (default:nusm).version: reserved in the public options; the current adapter opens version1.serialize: custom serializer (default:superjson.stringify).deserialize: custom deserializer (default:superjson.parse).pacer: persistence debouncer configuration (default: trailing, 100ms).
React Hooks
import { useStore } from "nusm/react";useStore uses React's useSyncExternalStore and supports selectors and
configurable equality checks.
useStore(store, selector?, options?)
Arguments:
store: a Nusm store instance returned bycreateNusmStore.selector(optional): function that receives the full state and returns the selected slice. Defaults to identity (returns full state).options(optional): configuration object with:equal: whentrue, uses deep equality (fast-equalsdeepEqual). Whenfalseor omitted, uses shallow equality (fast-equalsshallowEqual).
Example:
const store = createNusmStore({ user: { name: "Ada" } });
const name = useStore(store, (state) => state.user.name);
const user = useStore(store, (state) => state.user, { equal: true });Tests
bun testBuild
bun run buildLicense
MIT. See LICENSE.
TanStack Devtools inspector
nusm ships a framework-neutral event bridge plus an optional React panel built with
TanStack devtools-utils. The root nusm entry never imports React or the
Devtools shell.
bun add -d @tanstack/devtools @tanstack/devtools-utils \
@tanstack/react-devtools lucide-react react react-domimport { TanStackDevtools } from "@tanstack/react-devtools";
import { createNusmDevtoolsPlugin } from "nusm/devtools";
const nusmPlugin = createNusmDevtoolsPlugin();
export function DevelopmentTools() {
return (
<TanStackDevtools
config={{
defaultOpen: false,
position: "bottom-right",
triggerMode: "floating",
}}
plugins={[nusmPlugin]}
/>
);
}Instrument individual stores with devtools: true or a named configuration.
The panel provides a compact icon rail, searchable store sidebar, memory and
adapter tabs, path/value filtering, timeline history, copy, add, edit, remove,
raw JSON, reset, and refresh controls. Every mutation is sent through the typed
TanStack event bus and acknowledged by the target store.
Use createNoOpNusmDevtoolsPlugin() when a framework integration needs a stable
plugin shape without mounting the inspector. Never enable state inspection in
an untrusted production environment.
See the full Devtools guide and the example walkthrough, then run the standalone showcase from the repository root:
bun run example:devtools # Bun server with hot reload
bun run example:test # React and multi-adapter tests
bun run example:build # production HTML/CSS/JS bundle
bun run example:serve # serve the production bundleThe example walkthrough covers a Bun-native React application—no Vite or
similar build system—and exercises memory, localStorage, sessionStorage, and
IndexedDB stores. Its Command center, Stores, and Signals controls select real
visible views, and the canonical nusm mark identifies both the application and
the custom inspector panel. Open http://127.0.0.1:4173/ during hot-reload development. Production serving keeps the inspector disabled unless ?devtools is present, so use http://127.0.0.1:4173/?devtools only in a trusted environment. See the browser runtime QA receipt for the rebuilt panel, real adapter inspection, live mutation, responsive layout, and console checks.
