preact-sigma 2.2.0 → 2.2.2
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 +216 -62
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,60 +1,168 @@
|
|
|
1
1
|
# preact-sigma
|
|
2
2
|
|
|
3
|
-
`preact-sigma`
|
|
3
|
+
`preact-sigma` lets you define state once and reuse it as a model.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
It is built for Preact and TypeScript, and combines:
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
- fine-grained reactive reads
|
|
8
|
+
- Immer-style writes
|
|
9
|
+
- explicit setup and cleanup
|
|
10
|
+
- typed events
|
|
11
|
+
- a constructor you can instantiate anywhere
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
Use it when your state has started to feel like more than "some values in a component."
|
|
14
|
+
|
|
15
|
+
Instead of spreading logic across loose signals, reducers, effects, and cleanup code, you define one model with:
|
|
16
|
+
|
|
17
|
+
- top-level state
|
|
18
|
+
- derived reads
|
|
19
|
+
- write methods
|
|
20
|
+
- side-effect setup
|
|
21
|
+
- optional events
|
|
22
|
+
|
|
23
|
+
Then you create instances wherever they make sense: inside a component, in a shared module, or in plain TypeScript.
|
|
24
|
+
|
|
25
|
+
Under the hood, each top-level state property is backed by its own Preact signal, while writes happen through actions with Immer-backed mutation semantics.
|
|
26
|
+
|
|
27
|
+
## Why you would use it
|
|
28
|
+
|
|
29
|
+
`preact-sigma` is a good fit when you want state and behavior to live together.
|
|
30
|
+
|
|
31
|
+
It is especially useful when you need to:
|
|
32
|
+
|
|
33
|
+
- keep state, derived values, mutations, and lifecycle in one place
|
|
34
|
+
- create multiple instances of the same state model
|
|
35
|
+
- expose readonly public state while keeping writes explicit
|
|
36
|
+
- get fine-grained reactivity without wiring together a pile of loose signals
|
|
37
|
+
- own timers, subscriptions, listeners, or nested setup with clear cleanup
|
|
38
|
+
|
|
39
|
+
If a couple of plain signals are enough, use plain signals.
|
|
40
|
+
`preact-sigma` is for the point where state starts acting like a small system.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
10
43
|
|
|
11
44
|
```bash
|
|
12
45
|
npm install preact-sigma
|
|
13
46
|
```
|
|
14
47
|
|
|
15
|
-
|
|
48
|
+
## 30-second example
|
|
16
49
|
|
|
17
|
-
|
|
18
|
-
|
|
50
|
+
```ts
|
|
51
|
+
import { SigmaType } from "preact-sigma";
|
|
52
|
+
|
|
53
|
+
const Counter = new SigmaType<{ count: number }>()
|
|
54
|
+
.defaultState({
|
|
55
|
+
count: 0,
|
|
56
|
+
})
|
|
57
|
+
.computed({
|
|
58
|
+
doubled() {
|
|
59
|
+
return this.count * 2;
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
.actions({
|
|
63
|
+
increment() {
|
|
64
|
+
this.count += 1;
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const counter = new Counter();
|
|
69
|
+
|
|
70
|
+
counter.increment();
|
|
71
|
+
|
|
72
|
+
console.log(counter.count); // 1
|
|
73
|
+
console.log(counter.doubled); // 2
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
That example shows the basic shape:
|
|
77
|
+
|
|
78
|
+
- state is public and reactive
|
|
79
|
+
- derived values live in `computed(...)`
|
|
80
|
+
- writes happen in `actions(...)`
|
|
81
|
+
- an instance behaves like a small stateful object
|
|
82
|
+
|
|
83
|
+
## The mental model
|
|
84
|
+
|
|
85
|
+
A sigma model is made from a few simple pieces.
|
|
86
|
+
|
|
87
|
+
### `defaultState(...)`
|
|
88
|
+
|
|
89
|
+
Defines the top-level state for each instance.
|
|
90
|
+
|
|
91
|
+
Each top-level property becomes a reactive public property on the instance.
|
|
92
|
+
|
|
93
|
+
Use plain values for simple defaults, or zero-argument functions when each instance needs a fresh object or array.
|
|
94
|
+
|
|
95
|
+
### `computed(...)`
|
|
96
|
+
|
|
97
|
+
Use computeds for derived values that take no arguments.
|
|
98
|
+
|
|
99
|
+
They behave like tracked getters:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
.completedCount() // no
|
|
103
|
+
todoList.completedCount // yes
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### `queries(...)`
|
|
107
|
+
|
|
108
|
+
Use queries for reactive reads that need arguments.
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
visibleTodos("open");
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Queries are for reading, not writing.
|
|
115
|
+
|
|
116
|
+
### `actions(...)`
|
|
19
117
|
|
|
20
|
-
|
|
118
|
+
Actions are where state changes happen.
|
|
21
119
|
|
|
22
|
-
|
|
120
|
+
Outside an action, public state is readonly. Inside an action, you write with normal mutation syntax and sigma handles the draft/update flow for you.
|
|
23
121
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
122
|
+
```ts
|
|
123
|
+
.actions({
|
|
124
|
+
rename(title: string) {
|
|
125
|
+
this.title = title;
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### `setup(...)`
|
|
30
131
|
|
|
31
|
-
|
|
132
|
+
Setup is where side effects belong.
|
|
32
133
|
|
|
33
|
-
|
|
134
|
+
Use it for things like:
|
|
34
135
|
|
|
35
|
-
|
|
136
|
+
- timers
|
|
137
|
+
- event listeners
|
|
138
|
+
- subscriptions
|
|
139
|
+
- nested model setup
|
|
140
|
+
- storage sync
|
|
36
141
|
|
|
37
|
-
|
|
142
|
+
Setup is explicit. A new instance does not automatically run setup. When setup does run, it returns one cleanup function that tears down everything that instance owns.
|
|
38
143
|
|
|
39
|
-
|
|
40
|
-
- read public state directly while keeping writes inside typed action methods
|
|
41
|
-
- derive reactive values with computed getters and parameterized queries
|
|
42
|
-
- publish state changes from synchronous or async actions
|
|
43
|
-
- observe committed state changes and optional Immer patches
|
|
44
|
-
- snapshot committed top-level state and replace committed state for undo-like flows
|
|
45
|
-
- manage timers, listeners, nested state setup, and teardown through explicit cleanup
|
|
46
|
-
- use the same model inside Preact components with `useSigma(...)` and `useListener(...)`
|
|
144
|
+
### Events
|
|
47
145
|
|
|
48
|
-
|
|
146
|
+
Use events when the model needs to notify the outside world without exposing mutable internals.
|
|
147
|
+
|
|
148
|
+
Emit inside actions or setup:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
this.emit("saved", { count: 3 });
|
|
152
|
+
```
|
|
49
153
|
|
|
50
|
-
|
|
154
|
+
Listen from the outside:
|
|
51
155
|
|
|
52
|
-
|
|
156
|
+
```ts
|
|
157
|
+
const stop = instance.on("saved", ({ count }) => {
|
|
158
|
+
console.log(count);
|
|
159
|
+
});
|
|
160
|
+
```
|
|
53
161
|
|
|
54
|
-
##
|
|
162
|
+
## A more realistic example
|
|
55
163
|
|
|
56
164
|
```ts
|
|
57
|
-
import {
|
|
165
|
+
import { SigmaType } from "preact-sigma";
|
|
58
166
|
|
|
59
167
|
type Todo = {
|
|
60
168
|
id: string;
|
|
@@ -65,20 +173,18 @@ type Todo = {
|
|
|
65
173
|
const TodoList = new SigmaType<
|
|
66
174
|
{ draft: string; todos: Todo[]; saving: boolean },
|
|
67
175
|
{ saved: { count: number } }
|
|
68
|
-
>(
|
|
176
|
+
>()
|
|
69
177
|
.defaultState({
|
|
70
178
|
draft: "",
|
|
71
179
|
todos: [],
|
|
72
180
|
saving: false,
|
|
73
181
|
})
|
|
74
182
|
.computed({
|
|
75
|
-
// Computeds are tracked getters with no arguments.
|
|
76
183
|
remainingCount() {
|
|
77
184
|
return this.todos.filter((todo) => !todo.done).length;
|
|
78
185
|
},
|
|
79
186
|
})
|
|
80
187
|
.queries({
|
|
81
|
-
// Queries stay reactive at the call site and can accept arguments.
|
|
82
188
|
visibleTodos(filter: "all" | "open" | "done") {
|
|
83
189
|
return this.todos.filter((todo) => {
|
|
84
190
|
if (filter === "open") return !todo.done;
|
|
@@ -88,7 +194,6 @@ const TodoList = new SigmaType<
|
|
|
88
194
|
},
|
|
89
195
|
})
|
|
90
196
|
.actions({
|
|
91
|
-
// Public state is readonly, so writes live in actions.
|
|
92
197
|
setDraft(draft: string) {
|
|
93
198
|
this.draft = draft;
|
|
94
199
|
},
|
|
@@ -100,6 +205,7 @@ const TodoList = new SigmaType<
|
|
|
100
205
|
title: this.draft,
|
|
101
206
|
done: false,
|
|
102
207
|
});
|
|
208
|
+
|
|
103
209
|
this.draft = "";
|
|
104
210
|
},
|
|
105
211
|
toggleTodo(id: string) {
|
|
@@ -108,7 +214,7 @@ const TodoList = new SigmaType<
|
|
|
108
214
|
},
|
|
109
215
|
async save() {
|
|
110
216
|
this.saving = true;
|
|
111
|
-
this.commit(); //
|
|
217
|
+
this.commit(); // publish "saving" before awaiting
|
|
112
218
|
|
|
113
219
|
await fetch("/api/todos", {
|
|
114
220
|
method: "POST",
|
|
@@ -116,12 +222,11 @@ const TodoList = new SigmaType<
|
|
|
116
222
|
});
|
|
117
223
|
|
|
118
224
|
this.saving = false;
|
|
119
|
-
this.commit(); //
|
|
225
|
+
this.commit(); // publish before emitting
|
|
120
226
|
this.emit("saved", { count: this.todos.length });
|
|
121
227
|
},
|
|
122
228
|
})
|
|
123
229
|
.setup(function (storageKey: string) {
|
|
124
|
-
// Setup is explicit and returns cleanup resources.
|
|
125
230
|
const interval = window.setInterval(() => {
|
|
126
231
|
localStorage.setItem(storageKey, JSON.stringify(this.todos));
|
|
127
232
|
}, 1000);
|
|
@@ -130,44 +235,93 @@ const TodoList = new SigmaType<
|
|
|
130
235
|
});
|
|
131
236
|
|
|
132
237
|
const todoList = new TodoList();
|
|
133
|
-
|
|
134
|
-
// setup(...) returns one cleanup function for everything this instance owns.
|
|
135
238
|
const cleanup = todoList.setup("todos-demo");
|
|
136
239
|
|
|
137
|
-
// Queries are reactive where they are read.
|
|
138
|
-
const firstOpenTitle = computed(() => {
|
|
139
|
-
return todoList.visibleTodos("open")[0]?.title ?? "Nothing open";
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
// Events are typed and unsubscribe cleanly.
|
|
143
240
|
const stop = todoList.on("saved", ({ count }) => {
|
|
144
241
|
console.log(`Saved ${count} todos`);
|
|
145
242
|
});
|
|
146
243
|
|
|
147
|
-
todoList.setDraft("
|
|
244
|
+
todoList.setDraft("Rewrite the README");
|
|
148
245
|
todoList.addTodo();
|
|
149
|
-
await todoList.save();
|
|
150
246
|
|
|
151
247
|
console.log(todoList.remainingCount);
|
|
152
|
-
console.log(
|
|
248
|
+
console.log(todoList.visibleTodos("open"));
|
|
249
|
+
|
|
250
|
+
await todoList.save();
|
|
153
251
|
|
|
154
252
|
stop();
|
|
155
253
|
cleanup();
|
|
156
254
|
```
|
|
157
255
|
|
|
158
|
-
|
|
256
|
+
## The one rule to remember about actions
|
|
257
|
+
|
|
258
|
+
For normal synchronous actions, mutate state and return. You usually do **not** need `this.commit()`.
|
|
259
|
+
|
|
260
|
+
Use `this.commit()` when you have unpublished changes and the action is about to cross a boundary like:
|
|
261
|
+
|
|
262
|
+
- `await`
|
|
263
|
+
- `emit(...)`
|
|
264
|
+
- another action boundary that should not keep using the current draft
|
|
265
|
+
|
|
266
|
+
In practice, that means:
|
|
267
|
+
|
|
268
|
+
- sync action with no boundary: mutate and return
|
|
269
|
+
- async action before `await`: `commit()` if you want those changes published first
|
|
270
|
+
- action before `emit(...)`: `commit()` if there are pending changes
|
|
271
|
+
|
|
272
|
+
That rule is the main thing to learn beyond the basic API.
|
|
273
|
+
|
|
274
|
+
## In Preact
|
|
275
|
+
|
|
276
|
+
`preact-sigma` works outside components, but it also has a nice component story.
|
|
277
|
+
|
|
278
|
+
Use `useSigma(...)` when the component should own one instance:
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
import { useSigma } from "preact-sigma";
|
|
282
|
+
|
|
283
|
+
const todoList = useSigma(() => new TodoList(), ["todos-demo"]);
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
If the model defines setup handlers, `useSigma(...)` runs setup for that component-owned instance and cleans it up automatically when setup params change or the component unmounts.
|
|
159
287
|
|
|
160
|
-
|
|
288
|
+
Use `useListener(...)` for component-scoped event subscriptions:
|
|
161
289
|
|
|
162
|
-
|
|
290
|
+
```ts
|
|
291
|
+
import { useListener } from "preact-sigma";
|
|
163
292
|
|
|
164
|
-
|
|
293
|
+
useListener(todoList, "saved", ({ count }) => {
|
|
294
|
+
console.log(`Saved ${count} todos`);
|
|
295
|
+
});
|
|
296
|
+
```
|
|
165
297
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
-
|
|
171
|
-
-
|
|
172
|
-
-
|
|
173
|
-
-
|
|
298
|
+
## What you get out of the box
|
|
299
|
+
|
|
300
|
+
Beyond the core model API, `preact-sigma` also includes:
|
|
301
|
+
|
|
302
|
+
- `observe(...)` for reacting to committed state changes
|
|
303
|
+
- optional Immer patch delivery in observers
|
|
304
|
+
- `snapshot(...)` and `replaceState(...)` for restore/undo-like flows
|
|
305
|
+
- `get(key)` when you need direct signal access for a state key or computed
|
|
306
|
+
|
|
307
|
+
## Why this shape exists
|
|
308
|
+
|
|
309
|
+
`preact-sigma` exists for the space between two extremes:
|
|
310
|
+
|
|
311
|
+
- **too small for a big store abstraction**
|
|
312
|
+
- **too stateful for a handful of loose signals**
|
|
313
|
+
|
|
314
|
+
It keeps the ergonomics of working with a model object, while preserving:
|
|
315
|
+
|
|
316
|
+
- readonly public reads
|
|
317
|
+
- explicit write boundaries
|
|
318
|
+
- fine-grained reactivity
|
|
319
|
+
- explicit ownership of side effects
|
|
320
|
+
|
|
321
|
+
That makes it useful for app state that has real behavior, not just values.
|
|
322
|
+
|
|
323
|
+
## More docs
|
|
324
|
+
|
|
325
|
+
- [`llms.txt`](./llms.txt) contains the exhaustive API and behavior reference.
|
|
326
|
+
- Companion skills are available via `npx skills add alloc/preact-sigma`.
|
|
327
|
+
- The `preact-sigma` skill packages procedural guidance and agent-oriented workflow for the library.
|