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.
Files changed (2) hide show
  1. package/README.md +216 -62
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,60 +1,168 @@
1
1
  # preact-sigma
2
2
 
3
- `preact-sigma` is a typed state-model builder for apps that want Preact's fine-grained reactivity, Immer-backed writes, and explicit lifecycle.
3
+ `preact-sigma` lets you define state once and reuse it as a model.
4
4
 
5
- You define a reusable state type once, then create instances wherever they make sense: inside components, in shared modules, or in plain TypeScript code. Each instance exposes readonly public state, tracked derived reads, imperative actions, and optional setup and event APIs.
5
+ It is built for Preact and TypeScript, and combines:
6
6
 
7
- ## Getting Started
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
- To add `preact-sigma` to your project:
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
- If you use AI coding agents, this repo also includes agent-oriented guidance:
48
+ ## 30-second example
16
49
 
17
- - [llms.txt](./llms.txt) provides a compact overview of the API and recommended patterns.
18
- - Companion skills are available via `npx skills add alloc/preact-sigma`.
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
- ## What It Is
118
+ Actions are where state changes happen.
21
119
 
22
- At its core, `preact-sigma` lets you describe a stateful model as a constructor:
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
- - top-level state stays reactive through one signal per state property
25
- - computed values become tracked getters
26
- - queries become tracked methods, including queries with arguments
27
- - actions batch reads and writes through Immer drafts
28
- - setup handlers own side effects and cleanup
29
- - typed events let instances notify the outside world without exposing mutable internals
122
+ ```ts
123
+ .actions({
124
+ rename(title: string) {
125
+ this.title = title;
126
+ },
127
+ })
128
+ ```
129
+
130
+ ### `setup(...)`
30
131
 
31
- The result feels like a small stateful object from application code, while still behaving like signal-driven state from rendering code.
132
+ Setup is where side effects belong.
32
133
 
33
- ## What You Can Do With It
134
+ Use it for things like:
34
135
 
35
- `preact-sigma` is useful when you want state logic to live in one reusable unit instead of being split across loose signals, reducers, and effect cleanup code.
136
+ - timers
137
+ - event listeners
138
+ - subscriptions
139
+ - nested model setup
140
+ - storage sync
36
141
 
37
- With it, you can:
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
- - model domain state as reusable constructors instead of one-off store objects
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
- ## Why This Shape Exists
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
- This package exists to keep stateful logic cohesive without giving up signal-level reactivity.
154
+ Listen from the outside:
51
155
 
52
- It is a good fit when plain signals start to sprawl across modules, but heavier store abstractions feel too opaque or too tied to component structure. `preact-sigma` keeps the "model object" ergonomics of a class-like API, while preserving readonly public reads, explicit write boundaries, and explicit ownership of side effects.
156
+ ```ts
157
+ const stop = instance.on("saved", ({ count }) => {
158
+ console.log(count);
159
+ });
160
+ ```
53
161
 
54
- ## Big Picture Example
162
+ ## A more realistic example
55
163
 
56
164
  ```ts
57
- import { computed, SigmaType } from "preact-sigma";
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
- >("TodoList")
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(); // Publish the loading state before awaiting.
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(); // Publish post-await writes explicitly.
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("Write the README");
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(firstOpenTitle.value);
248
+ console.log(todoList.visibleTodos("open"));
249
+
250
+ await todoList.save();
153
251
 
154
252
  stop();
155
253
  cleanup();
156
254
  ```
157
255
 
158
- In Preact, the same constructor can be used with `useSigma(() => new TodoList(), ["todos-demo"])` so the component owns one instance and `setup(...)` cleanup runs automatically. Use `useListener(...)` when you want component-scoped event subscriptions with automatic teardown.
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
- Cleanup resources can be returned as functions, `AbortController`, objects with `dispose()`, or objects with `Symbol.dispose`.
288
+ Use `useListener(...)` for component-scoped event subscriptions:
161
289
 
162
- Inside setup, `this` exposes the public instance plus `emit(...)` and `act(fn)`. Use `this.act(function () { ... })` when setup needs one synchronous anonymous action with normal draft, `commit()`, and `emit(...)` semantics, whether that work happens immediately in the setup body or later from a setup-owned callback, but should not become a public action method.
290
+ ```ts
291
+ import { useListener } from "preact-sigma";
163
292
 
164
- ## Best Practices
293
+ useListener(todoList, "saved", ({ count }) => {
294
+ console.log(`Saved ${count} todos`);
295
+ });
296
+ ```
165
297
 
166
- - Let `new SigmaType<TState, TEvents>()` and the builder inputs drive inference. Avoid forcing extra type arguments onto builder methods.
167
- - Keep top-level state properties meaningful. Each top-level property gets its own signal, so shape state around the reads you want to track.
168
- - Use `computed(...)` for argument-free derived state, and use queries for reactive reads that need parameters.
169
- - Put writes in actions. A draft boundary is any point where sigma cannot keep reusing the current draft. `emit()`, `await`, and any action call other than a same-instance sync nested action call are draft boundaries, so call `this.commit()` before those boundaries when pending writes should become public. Setup can use `this.act(function () { ... })` to run one synchronous anonymous action for initialization work or setup-owned callbacks without adding a public action method.
170
- - Use `snapshot(instance)` and `replaceState(instance, snapshot)` for committed-state replay. They work on top-level state keys and stay outside action semantics.
171
- - Use `SigmaRef<T>` when a value should stay by reference in sigma's `Draft` and `Immutable` types. A normal assignment to a `SigmaRef<T>`-typed value only changes typing and does not change Immer's runtime drafting or freezing behavior.
172
- - Use `immerable` on custom classes only when they should participate in Immer drafting. `setAutoFreeze(false)` disables sigma's runtime deep-freezing when you need published state to stay unfrozen.
173
- - Use `setup(...)` for owned side effects, and always return cleanup resources for anything the instance starts.
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "preact-sigma",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "keywords": [],
5
5
  "license": "MIT",
6
6
  "author": "Alec Larson",