@nice-code/state 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1 +1,262 @@
1
- # @nice-code/state
1
+ # @nice-code/state
2
+
3
+ Framework-agnostic, Immer-backed state container with fine-grained selector subscriptions, derived reactions, patch streams, and a drop-in React adapter + devtools panel.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add @nice-code/state immer
9
+ ```
10
+
11
+ `react` is an optional peer dependency (>=19) — only needed for the `@nice-code/state/react` adapter.
12
+
13
+ ## Why
14
+
15
+ - **One immutable store, mutated with Immer** — write plain mutations (`s.count += 1`), get structural sharing for free.
16
+ - **No-op updates are genuinely free** — listeners only fire when the root reference actually changes.
17
+ - **Fine-grained selectors** — components and side-effects re-run only when their watched slice changes structurally, not on every update.
18
+ - **Tear-free React** — the adapter is built on `useSyncExternalStore` (React 18+ concurrent-safe), no provider/context required.
19
+ - **Patches + devtools** — every mutation can emit Immer patches; the optional devtools panel gives you a timeline, state inspector, diff view, and revert.
20
+
21
+ ---
22
+
23
+ ## Core concepts
24
+
25
+ - **Store** — owns one immutable state value and a set of listeners.
26
+ - **update** — mutate the state through an Immer draft.
27
+ - **watch** — subscribe to a derived slice; fires only on structural change (outside React).
28
+ - **createReaction** — derive further state from the store's own mutations.
29
+ - **useStoreState** — subscribe a React component to a store or a selected slice.
30
+
31
+ ---
32
+
33
+ ## Create a store
34
+
35
+ ```ts
36
+ import { Store } from "@nice-code/state";
37
+
38
+ interface ICounterState {
39
+ count: number;
40
+ step: number;
41
+ }
42
+
43
+ // Pass a value, or a factory function (re-used by SSR / reset).
44
+ export const counterStore = new Store<ICounterState>({ count: 0, step: 1 });
45
+ ```
46
+
47
+ ## Update state
48
+
49
+ Mutations run through Immer — mutate the `draft` freely, the store commits a new immutable state.
50
+
51
+ ```ts
52
+ // Single update
53
+ counterStore.update((s) => {
54
+ s.count += s.step;
55
+ });
56
+
57
+ // Batched — an array of updaters applied in order, committed as one change
58
+ counterStore.update([
59
+ (s) => { s.count += 1; },
60
+ (s) => { s.count += 1; },
61
+ ]);
62
+
63
+ // Replace the whole state
64
+ counterStore.replace({ count: 0, step: 1 });
65
+
66
+ // Replace by mapping from the current state
67
+ counterStore.replaceFromCurrent((s) => ({ ...s, count: 0 }));
68
+ ```
69
+
70
+ The second argument to an updater is a readonly snapshot of the pre-update state:
71
+
72
+ ```ts
73
+ counterStore.update((draft, original) => {
74
+ draft.count = original.count * 2;
75
+ });
76
+ ```
77
+
78
+ ## Read state
79
+
80
+ ```ts
81
+ const { count } = counterStore.getRawState();
82
+ ```
83
+
84
+ > Reach for `getRawState()` only in plain logic. In components use `useStoreState`; for side-effects use `watch`.
85
+
86
+ ---
87
+
88
+ ## React adapter
89
+
90
+ Import from `@nice-code/state/react`. No provider needed — pass the store directly.
91
+
92
+ ```tsx
93
+ import { useStoreState } from "@nice-code/state/react";
94
+
95
+ function Counter() {
96
+ // Select a slice — re-renders only when `count` changes.
97
+ const count = useStoreState(counterStore, (s) => s.count);
98
+
99
+ return (
100
+ <button onClick={() => counterStore.update((s) => { s.count += 1; })}>
101
+ {count}
102
+ </button>
103
+ );
104
+ }
105
+ ```
106
+
107
+ ```tsx
108
+ // No selector → subscribe to the whole state.
109
+ const state = useStoreState(counterStore);
110
+ ```
111
+
112
+ ### Selecting derived/inline values
113
+
114
+ By default selection uses a strict-reference check (perfect for state branches, thanks to Immer's structural sharing). When a selector builds a **new** object/array each call, pass an equality function so equal-but-new results don't re-render:
115
+
116
+ ```tsx
117
+ import { useStoreState } from "@nice-code/state/react";
118
+ import { deepEqual } from "fast-equals";
119
+
120
+ const activeTodos = useStoreState(
121
+ store,
122
+ (s) => s.todos.filter((t) => !t.done),
123
+ deepEqual,
124
+ );
125
+ ```
126
+
127
+ ### Component-local stores
128
+
129
+ `useLocalStore` creates a store scoped to a component instance, persisted across renders. Pass a `deps` array to re-create it (with fresh initial state) when dependencies change.
130
+
131
+ ```tsx
132
+ import { useLocalStore, useStoreState } from "@nice-code/state/react";
133
+
134
+ function Editor({ docId }: { docId: string }) {
135
+ const store = useLocalStore(() => ({ draft: "" }), [docId]);
136
+ const draft = useStoreState(store, (s) => s.draft);
137
+ // ...
138
+ }
139
+ ```
140
+
141
+ ### Render-prop binding
142
+
143
+ `InjectStoreState` subscribes inline without writing a hook:
144
+
145
+ ```tsx
146
+ import { InjectStoreState } from "@nice-code/state/react";
147
+
148
+ <InjectStoreState store={counterStore} on={(s) => s.count}>
149
+ {(count) => <span>{count}</span>}
150
+ </InjectStoreState>
151
+ ```
152
+
153
+ ---
154
+
155
+ ## Reactions — derive state from state
156
+
157
+ A reaction watches a slice and, when it changes, runs a recipe inside Immer to derive further state on the **same** store. Reactions run before subscribers are notified, so derived fields are always consistent in a single render.
158
+
159
+ ```ts
160
+ interface IState {
161
+ count: number;
162
+ doubled: number; // derived — never written by hand
163
+ history: number[]; // derived
164
+ }
165
+
166
+ const store = new Store<IState>({ count: 0, doubled: 0, history: [] });
167
+
168
+ store.createReaction(
169
+ (s) => s.count, // watch
170
+ (count, draft) => { // derive
171
+ draft.doubled = count * 2;
172
+ draft.history = [...draft.history, count].slice(-12);
173
+ },
174
+ { runNow: true }, // run once immediately to seed derived state
175
+ );
176
+ ```
177
+
178
+ `createReaction` returns a disposer to remove the reaction.
179
+
180
+ ---
181
+
182
+ ## Watch — side-effects outside React
183
+
184
+ `watch` subscribes to a derived slice and fires the listener only when it changes structurally. Ideal for logging, persistence, or syncing to non-React code.
185
+
186
+ ```ts
187
+ const unsubscribe = store.watch(
188
+ (s) => s.count,
189
+ (count, allState, previousCount) => {
190
+ console.log(`count: ${previousCount} → ${count}`);
191
+ },
192
+ );
193
+ ```
194
+
195
+ The low-level `store.subscribe(() => { ... })` fires on every committed update (no slice, no args) — this is the primitive the React adapter builds on.
196
+
197
+ ---
198
+
199
+ ## Patches
200
+
201
+ Every update can emit [Immer patches](https://immerjs.github.io/immer/patches/), enabling undo/redo, sync, and the devtools. Patches are only computed when there's a consumer, so the common path stays allocation-free.
202
+
203
+ ```ts
204
+ import { applyPatchesToStore } from "@nice-code/state";
205
+
206
+ // Listen to patches for every update
207
+ const stop = store.listenToPatches((patches, inversePatches) => {
208
+ sendToServer(patches);
209
+ });
210
+
211
+ // Or collect patches for a single update
212
+ store.update((s) => { s.count += 1; }, (patches, inverse) => {
213
+ undoStack.push(inverse);
214
+ });
215
+
216
+ // Apply patches (e.g. received from elsewhere, or to undo)
217
+ store.applyPatches(patches);
218
+ ```
219
+
220
+ ---
221
+
222
+ ## Devtools
223
+
224
+ A zero-config inspector for your stores — timeline of changes, before/after diffs, live state inspector with direct editing, and patch-based revert. Import the panel from `@nice-code/state/devtools/browser`.
225
+
226
+ ```ts
227
+ // devtools.ts — register the stores you want to observe
228
+ import { StateDevtoolsCore } from "@nice-code/state/devtools/browser";
229
+ import { counterStore } from "./counterStore";
230
+
231
+ export const stateDevtools = new StateDevtoolsCore();
232
+ stateDevtools.registerStore("counterStore", counterStore);
233
+ ```
234
+
235
+ ```tsx
236
+ // Mount once near your app root. Renders nothing in production
237
+ // (NODE_ENV !== "development").
238
+ import { NiceStateDevtools } from "@nice-code/state/devtools/browser";
239
+ import { stateDevtools } from "./devtools";
240
+
241
+ <NiceStateDevtools core={stateDevtools} position="dock-bottom" />
242
+ ```
243
+
244
+ `position` is one of `"dock-bottom"` | `"dock-top"` | `"dock-left"` | `"dock-right"`. The panel lets you filter by store, inspect/edit live state, view per-change diffs and patches, pause the timeline, and revert a change via its inverse patches.
245
+
246
+ > Registering a store attaches a patch listener (a negligible dev-time cost). Keep devtools setup out of production bundles.
247
+
248
+ ---
249
+
250
+ ## SSR
251
+
252
+ `useStoreState` is built on `useSyncExternalStore` with a server snapshot, so it renders the store's current value on the server without subscribing. Construct stores with a factory (`new Store(() => initialState())`) so each request can seed its own state.
253
+
254
+ ---
255
+
256
+ ## Exports
257
+
258
+ | Entry | Contents |
259
+ | --- | --- |
260
+ | `@nice-code/state` | `Store`, `update`, `applyPatchesToStore`, and all core types |
261
+ | `@nice-code/state/react` | `useStoreState`, `useLocalStore`, `InjectStoreState` |
262
+ | `@nice-code/state/devtools/browser` | `StateDevtoolsCore`, `NiceStateDevtools` |
@@ -472,9 +472,15 @@ import {
472
472
  import { jsxDEV as jsxDEV3 } from "react/jsx-dev-runtime";
473
473
  var DOCKED_SIZE_MIN = 160;
474
474
  var POSITION_GRID = [
475
- [null, "dock-top", null],
476
- ["dock-left", null, "dock-right"],
477
- [null, "dock-bottom", null]
475
+ { key: "tl", pos: null },
476
+ { key: "tc", pos: "dock-top" },
477
+ { key: "tr", pos: null },
478
+ { key: "ml", pos: "dock-left" },
479
+ { key: "mc", pos: null },
480
+ { key: "mr", pos: "dock-right" },
481
+ { key: "bl", pos: null },
482
+ { key: "bc", pos: "dock-bottom" },
483
+ { key: "br", pos: null }
478
484
  ];
479
485
  function getDockSide(pos) {
480
486
  switch (pos) {
@@ -606,12 +612,11 @@ function PositionPicker({
606
612
  return /* @__PURE__ */ jsxDEV3("div", {
607
613
  title: "Move / dock panel",
608
614
  style: { display: "grid", gridTemplateColumns: "repeat(3, 9px)", gap: "2px", padding: "2px" },
609
- children: POSITION_GRID.flat().map((pos) => {
615
+ children: POSITION_GRID.map(({ key, pos }) => {
610
616
  if (pos == null)
611
617
  return /* @__PURE__ */ jsxDEV3("div", {
612
618
  style: { width: "9px", height: "9px" }
613
- }, "center-empty", false, undefined, this);
614
- const isDock = pos.startsWith("dock-");
619
+ }, key, false, undefined, this);
615
620
  const isTopBottom = pos === "dock-top" || pos === "dock-bottom";
616
621
  const isActive = pos === position;
617
622
  return /* @__PURE__ */ jsxDEV3("div", {
@@ -627,13 +632,13 @@ function PositionPicker({
627
632
  },
628
633
  children: /* @__PURE__ */ jsxDEV3("div", {
629
634
  style: {
630
- width: isDock ? isTopBottom ? "9px" : "3px" : "7px",
631
- height: isDock ? isTopBottom ? "3px" : "9px" : "7px",
632
- borderRadius: isDock ? "1px" : "50%",
635
+ width: isTopBottom ? "9px" : "3px",
636
+ height: isTopBottom ? "3px" : "9px",
637
+ borderRadius: "1px",
633
638
  background: isActive ? DEVTOOL_COLOR_SEMANTIC_SYSTEM : DEVTOOL_COLOR_TEXT_FAINT
634
639
  }
635
640
  }, undefined, false, undefined, this)
636
- }, pos, false, undefined, this);
641
+ }, key, false, undefined, this);
637
642
  })
638
643
  }, undefined, false, undefined, this);
639
644
  }
@@ -1467,12 +1472,12 @@ function NiceStateDevtools_Panel({
1467
1472
  const [selectedChangeCuid, setSelectedChangeCuid] = useState4(null);
1468
1473
  useEffect2(() => core.subscribe(setSnapshot), [core]);
1469
1474
  const setPrefs = (update) => {
1470
- setPrefsRaw((prev) => {
1471
- const next = { ...prev, ...update };
1472
- writePrefs(next);
1473
- return next;
1474
- });
1475
+ setPrefsRaw((prev) => ({ ...prev, ...update }));
1475
1476
  };
1477
+ useEffect2(() => {
1478
+ const timer = setTimeout(() => writePrefs(prefs), 250);
1479
+ return () => clearTimeout(timer);
1480
+ }, [prefs]);
1476
1481
  const { stores, changes, paused } = snapshot;
1477
1482
  const { position, isOpen, dockedHeight, dockedWidth, detailRatio } = prefs;
1478
1483
  const dockSide = getDockSide(position);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nice-code/state",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {