nexus-state
v4.0.0
Published
Lightweight, traceable global state manager for JavaScript and React.
Maintainers
Readme

Table of contents
About
Lightweight, framework-agnostic state management with optional actions, React bindings, and traceable updates. Designed for simplicity and performance, with first-class TypeScript inference.
What makes it different:
- Traceable state. Every update carries a
contextdescribing where it came from ("server","storage","reset", or your own). That context flows through both middleware and subscribers — so persistence, sync, and devtools can tell a user action from a hydration without guessing. - Key-level subscriptions. Subscribers listen to specific keys, so an update only notifies what actually depends on it — no selector is re-run for components that didn't change.
- Optional React. The core has zero dependencies and no React. Persistence is available from the main entry point; React hooks live behind a separate entry point you opt into.
- Inference-first types. State and actions are inferred from your config — you rarely write a generic by hand.
Installation
npm install nexus-stateReact is an optional peer dependency — only needed if you import
nexus-state/react.
| Import | Contents | Needs |
| ---------------------- | -------------------------------------- | ------------ |
| nexus-state | createNexus, createActs, persist | — |
| nexus-state/react | createReactNexus | react (peer) |
| nexus-state/devtools | devtools (Redux DevTools adapter) | — |
import { createNexus, createActs, persist } from "nexus-state";
import { createReactNexus } from "nexus-state/react";
import { devtools } from "nexus-state/devtools";Quick start
You can create two kinds of store — a framework-agnostic core, or a React store with hooks:
import { createNexus } from "nexus-state";
const counter = createNexus({
state: { count: 0 },
acts: (get, set) => ({
increment() {
set((s) => ({ count: s.count + 1 }));
},
}),
});
counter.acts.increment();
counter.get("count"); // number — no generics needed, types are inferredimport { createReactNexus } from "nexus-state/react";
const counter = createReactNexus({
state: { count: 0 },
acts: (get, set) => ({
increment() {
set((s) => ({ count: s.count + 1 }));
},
}),
});
function Counter() {
const count = counter.use("count"); // re-renders on change
return <button onClick={counter.acts.increment}>{count}</button>;
}API
— CORE —
// your-nexus-config
import { createNexus } from "nexus-state";
const nexus = createNexus({
state: {
count1: 0,
count2: 0,
},
acts: (get, set) => ({
increment() {
set((state) => ({ count1: state.count1 + 1 }));
this.getState("count1"); // ! calling another action
},
getState(value) {
console.log(`${value}:`, get(value));
},
}),
});
export default nexus;State and action types are inferred from your config, so most stores need no generics:
const nexus = createNexus({
state: { count: 0 },
acts: (get, set) => ({
inc() {
set((s) => ({ count: s.count + 1 }));
},
}),
});
nexus.get("count"); // number
nexus.acts.inc(); // () => voidPass generics explicitly only when you want to declare the shape up front:
createNexus<MyState, MyActions>({ ... });// your-nexus-config
import { createNexus, createActs } from "nexus-state";
const counterActs = createActs((get, set) => ({
increment() {
set((state) => ({ count1: state.count1 + 1 }));
this.getState("count1"); // ! calling another action inside
},
getState(value) {
console.log(`${value}:`, get(value));
},
}));
const nexus = createNexus({
state: {...},
acts: counterActs, // ! supports multiple too: [counterActs, otherActs]
});
export default nexus;type MyState = {...};
type MyActions = {...};
// `this` is typed as the complete acts object across every slice,
// so cross-slice calls are fully typed — no optional chaining needed.
const counterActs = createActs<MyState, MyActions>(function (get, set) {
return {
increment() {
this.getState("count1"); // typed
},
};
});import { createNexus, persist } from "nexus-state";
const nexus = createNexus({ state: { theme: "light", count: 0 } });
const stop = persist(nexus, {
key: "my-app",
include: ["theme"], // persist only the theme
version: 1,
migrate: (old) => ({ theme: old.theme ?? "light" }),
});
// later: stop() to disable persistence// sessionStorage works through the same storage interface
persist(nexus, {
key: "my-app-session",
storage: sessionStorage,
});For async loading (server, IndexedDB wrappers, files, etc.), load the data outside of persist and write it with context:
const profile = await loadProfile();
nexus.set({ profile }, { source: "server" });— REACT —
// your-nexus-config
import { createReactNexus } from "nexus-state/react"; // import with /react
const nexus = createReactNexus({
state: {
count1: 0,
count2: 0,
},
acts: (get, set) => ({
increment() {
set((state) => ({ count1: state.count1 + 1 }));
this.getState("count1"); // ! calling another action
},
getState(value) {
console.log(`${value}:`, get(value));
},
}),
});
export default nexus;// The acts generic is optional — omitting it no longer causes an error.
const nexus = createReactNexus({ state: {...}, acts: (get, set) => ({...}) });
// Explicit form, if you prefer to declare shapes:
const typed = createReactNexus<MyState, MyActions>({...});— CORE —
import nexus from "your-nexus-config";
const entireState = nexus.get();
const specificValue = nexus.get("key");import nexus from "your-nexus-config";
nexus.set({ count1: 5, count2: 10 }); // partial, one notification
nexus.set((state) => ({ count1: state.count1 + 1 })); // functional
nexus.set({ user }, "server"); // provenance shortcut for { source: "server" }
nexus.set({ user }, { source: "server", meta: { requestId: 7 } });✦ Sources: known values (
"manual" | "storage" | "server" | "external" | "reset") autocomplete, but any string works. Sources starting with @@ are reserved for the library. ✦ Batching: one set with several keys notifies once — the primary way to batch. set calls made synchronously inside an action are collapsed into one too.
import nexus from "your-nexus-config";
nexus.reset(); // reset entire state
nexus.reset("count1", "count2"); // reset specific keysimport nexus from "your-nexus-config";
const unsubscribe = nexus.subscribe(
(state, context) => {
console.log("count1 changed:", state.count1, "from", context?.source);
},
["count1"],
);
// A subscriber watching several keys is notified once per update, not per key.
nexus.subscribe((state) => save(state), ["count1", "count2"]);
// Watch every key:
nexus.subscribe((state) => save(state), ["*"]);
// later: unsubscribe() to disable subscribingimport nexus from "your-nexus-config";
const remove = nexus.middleware((prev, next, context) => {
if (context?.source === "storage") {
console.log("Loaded from storage:", next);
}
// Return a modified state, or nothing for a pure side effect.
return next;
});
// later: remove() to detach middlewareDescription: object containing your custom actions. Usage Example:
import nexus from "your-nexus-config";
nexus.acts.increment();
nexus.acts.getState("count1");✦ Provenance: updates a set makes inside an action inherit the action name as their source (unless the set passes its own context), so subscribers, persist and devtools can tell which action changed the state. Nested this.other() calls keep the outer action's name.
Important: regular functions support calling other actions via this, arrow functions are more compact but don't:
// regular function
increment() {
this.getState("count1"); // works
}
// arrow function
increment: () => this.getState("count1"); // not worksMore info: Arrow Functions
— REACT —
import nexus from "your-nexus-config";
const entireState = nexus.use();
const specificValue = nexus.use("key");✦ Note: Unlike get, use triggers a re-render when the watched state changes.
import nexus from "your-nexus-config";
// Subscribes to count1 and count2 only — inferred from the reads, no deps array:
const total = nexus.useSelector((s) => s.count1 + s.count2);
// New array/object each run (.map/.filter/literal) — pass "shallow" so an
// equal result doesn't re-render:
const ids = nexus.useSelector((s) => s.items.map((i) => i.id), "shallow");
// Escape hatch — custom comparator (e.g. arrays of objects):
const rows = nexus.useSelector(
(s) => s.users.map((u) => ({ id: u.id, name: u.name })),
(a, b) => a.length === b.length && a.every((x, i) => x.id === b[i].id),
);✦ Note: read state through the selector argument, not nexus.get() — reads that bypass the argument aren't tracked. Comparison is on the selector's result: a tracked key changing re-runs the selector, but an equal result won't re-render. Selector/comparator are read from refs, so no useCallback is needed.
import nexus from "your-nexus-config";
const rerender = nexus.useRerender();
rerender(); // force re-renderRecipes
Integrations and patterns. DevTools ships as a tiny adapter; Immer and SSR are
pure recipes over set / React context — nexus doesn't ship code it doesn't need.
import { createNexus } from "nexus-state";
import { devtools } from "nexus-state/devtools";
const nexus = createNexus({ state: { count: 0 } });
const stop = devtools(nexus, { name: "MyStore" });
nexus.set({ count: 1 }, "user"); // shows up as action "user"
// stop() disconnectsimport { createNexus } from "nexus-state";
import { produce } from "immer";
const nexus = createNexus({
state: {
/* any shape, however nested */
},
acts: (get, set) => ({
// one action can change anything on the draft:
update() {
set(
produce(get(), (s) => {
// s is your typed state — mutate whatever you need:
// s.a.b.c = value;
// s.list.push(item);
// delete s.map[id];
}),
);
},
}),
});
// Outside an action it's the same one-liner:
nexus.set(
produce(nexus.get(), (s) => {
// mutate the draft
}),
);✦
get()types the draft, so no generics are needed. Want a bound setter? It's a one-liner you own:const draft = (r) => nexus.set(produce(nexus.get(), r));
// store.ts — a factory, not a singleton
import { createReactNexus } from "nexus-state/react";
export function createStore(initial?: Partial<{ count: number }>) {
return createReactNexus({
state: { count: 0, ...initial },
acts: (get, set) => ({
increment() {
set((s) => ({ count: s.count + 1 }));
},
}),
});
}
export type Store = ReturnType<typeof createStore>;// store-provider.tsx — standard React context (you own this ~10 lines)
"use client";
import { createContext, useContext, useRef, type ReactNode } from "react";
import { createStore, type Store } from "./store";
const StoreContext = createContext<Store | null>(null);
export function StoreProvider({
initial,
children,
}: {
initial?: Partial<{ count: number }>;
children: ReactNode;
}) {
// create once per mount/request — never re-create on render
const ref = useRef<Store>();
if (!ref.current) ref.current = createStore(initial);
return (
<StoreContext.Provider value={ref.current}>
{children}
</StoreContext.Provider>
);
}
export function useStore() {
const store = useContext(StoreContext);
if (!store) throw new Error("useStore must be used within <StoreProvider>");
return store;
}// pass server data as `initial` (matches the server-rendered HTML):
<StoreProvider initial={{ count: serverCount }}>
<App />
</StoreProvider>;
// in a component — read via context, then use the store's hooks:
function Counter() {
const store = useStore();
const count = store.use("count");
return <button onClick={store.acts.increment}>{count}</button>;
}✦ With persist:
localStorageonly exists on the client, sopersistis a no-op on the server. Initialize the store with server data for the first render (so it matches the server HTML), and callpersistclient-side after hydration (e.g. in auseEffect) so stored values don't cause a hydration mismatch.
