@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 +262 -1
- package/build/devtools/browser/index.js +20 -15
- package/package.json +1 -1
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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.
|
|
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
|
-
},
|
|
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:
|
|
631
|
-
height:
|
|
632
|
-
borderRadius:
|
|
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
|
-
},
|
|
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);
|