preact-sigma 1.0.1 → 2.0.1

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 (5) hide show
  1. package/README.md +119 -460
  2. package/dist/index.d.mts +207 -193
  3. package/dist/index.mjs +593 -265
  4. package/llms.txt +343 -292
  5. package/package.json +1 -1
package/README.md CHANGED
@@ -1,509 +1,168 @@
1
- # `preact-sigma`
1
+ # preact-sigma
2
2
 
3
- Managed UI state for Preact Signals, with Immer-powered updates and a small public API.
3
+ `preact-sigma` is a typed state-model builder for apps that want Preact's fine-grained reactivity, Immer-backed writes, and explicit lifecycle.
4
4
 
5
- For naming and API design conventions, see [best-practices.md](./best-practices.md).
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.
6
6
 
7
- ## Install
7
+ ## Getting Started
8
8
 
9
- ```sh
10
- pnpm add preact-sigma
11
- ```
9
+ To add `preact-sigma` to your project:
12
10
 
13
- ```sh
11
+ ```bash
14
12
  npm install preact-sigma
15
13
  ```
16
14
 
17
- ## Big Picture
18
-
19
- Define state once, expose a few methods, and return reactive immutable data from the public instance.
20
-
21
- ```ts
22
- import { computed, defineManagedState, type StateHandle } from "preact-sigma";
23
-
24
- type CounterEvents = {
25
- thresholdReached: [{ count: number }];
26
- };
27
-
28
- type CounterState = number;
29
-
30
- const Counter = defineManagedState(
31
- (counter: StateHandle<CounterState, CounterEvents>, step: number) => {
32
- const doubled = computed(() => counter.get() * 2);
33
-
34
- return {
35
- count: counter,
36
- doubled,
37
- increment() {
38
- counter.set((value) => value + step);
39
-
40
- if (counter.get() >= 10) {
41
- counter.emit("thresholdReached", { count: counter.get() });
42
- }
43
- },
44
- reset() {
45
- counter.set(0);
46
- },
47
- };
48
- },
49
- 0,
50
- );
51
-
52
- const counter = new Counter(2);
53
- const stopThreshold = counter.on("thresholdReached", (event) => {
54
- console.log(event.count);
55
- });
56
-
57
- counter.count;
58
- counter.doubled;
59
- counter.increment();
60
- stopThreshold();
61
- ```
62
-
63
- - `count: counter` exposes the base state as a reactive immutable property.
64
- - `doubled` is a memoized reactive value exposed through `computed()`.
65
- - `increment()` is action-wrapped automatically, so the state update is batched and untracked.
66
- - `counter.on(...)` returns `stopThreshold`, which unsubscribes the event listener.
67
-
68
- ## Define Reusable State
69
-
70
- Use `defineManagedState()` when you want a reusable managed-state class.
71
-
72
- ```ts
73
- import { defineManagedState, type StateHandle } from "preact-sigma";
74
-
75
- type CounterState = number;
76
-
77
- const Counter = defineManagedState(
78
- (counter: StateHandle<CounterState>) => ({
79
- count: counter,
80
- increment() {
81
- counter.set((value) => value + 1);
82
- },
83
- }),
84
- 0,
85
- );
86
- ```
87
-
88
- ## Expose Base State
15
+ If you use AI coding agents, this repo also includes agent-oriented guidance:
89
16
 
90
- Return the constructor handle when you want the base state to appear as a reactive immutable property.
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`.
91
19
 
92
- ```ts
93
- import { defineManagedState, type StateHandle } from "preact-sigma";
94
-
95
- type CounterState = number;
96
-
97
- const Counter = defineManagedState(
98
- (count: StateHandle<CounterState>) => ({
99
- count,
100
- }),
101
- 0,
102
- );
103
-
104
- new Counter().count;
105
- ```
106
-
107
- ## Memoize A Reactive Derivation
108
-
109
- Use `computed()` when you want a memoized reactive value on the public instance.
110
-
111
- ```ts
112
- import { computed, defineManagedState, type StateHandle } from "preact-sigma";
113
-
114
- type CounterState = number;
20
+ ## What It Is
115
21
 
116
- const Counter = defineManagedState(
117
- (counter: StateHandle<CounterState>) => ({
118
- doubled: computed(() => counter.get() * 2),
119
- }),
120
- 0,
121
- );
22
+ At its core, `preact-sigma` lets you describe a stateful model as a constructor:
122
23
 
123
- new Counter().doubled;
124
- ```
125
-
126
- ## Create A Tracked Query Method
127
-
128
- Use `query()` when you want a public method whose reads stay tracked.
129
- Query functions read from closed-over handles or signals and do not use
130
- instance `this`.
131
-
132
- ```ts
133
- import { defineManagedState, query, type StateHandle } from "preact-sigma";
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
134
30
 
135
- type CounterState = number;
31
+ The result feels like a small stateful object from application code, while still behaving like signal-driven state from rendering code.
136
32
 
137
- const Counter = defineManagedState(
138
- (counter: StateHandle<CounterState>) => ({
139
- isPositive: query(() => counter.get() > 0),
140
- }),
141
- 0,
142
- );
33
+ ## What You Can Do With It
143
34
 
144
- new Counter().isPositive();
145
- ```
146
-
147
- ## Read Base State Without Tracking
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.
148
36
 
149
- Use `handle.peek()` when you need the current base-state snapshot without creating a reactive dependency.
37
+ With it, you can:
150
38
 
151
- ```ts
152
- import { defineManagedState, type StateHandle } from "preact-sigma";
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(...)`
153
47
 
154
- type CounterState = number;
48
+ ## Why This Shape Exists
155
49
 
156
- const Counter = defineManagedState(
157
- (counter: StateHandle<CounterState>) => ({
158
- logNow() {
159
- console.log(counter.peek());
160
- },
161
- }),
162
- 0,
163
- );
164
- ```
50
+ This package exists to keep stateful logic cohesive without giving up signal-level reactivity.
165
51
 
166
- ## Use Top-Level Lenses
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.
167
53
 
168
- When the base state is object-shaped, the constructor handle exposes a shallow lens for each top-level property, and you can return that lens directly.
54
+ ## Big Picture Example
169
55
 
170
56
  ```ts
171
- import { computed, defineManagedState, type StateHandle } from "preact-sigma";
57
+ import { computed, SigmaType } from "preact-sigma";
172
58
 
173
- type SearchState = {
174
- query: string;
59
+ type Todo = {
60
+ id: string;
61
+ title: string;
62
+ done: boolean;
175
63
  };
176
64
 
177
- const Search = defineManagedState(
178
- (search: StateHandle<SearchState>) => ({
179
- query: search.query,
180
- trimmedQuery: computed(() => search.query.get().trim()),
181
- setQuery(query: string) {
182
- search.query.set(query);
65
+ const TodoList = new SigmaType<
66
+ { draft: string; todos: Todo[]; saving: boolean },
67
+ { saved: { count: number } }
68
+ >("TodoList")
69
+ .defaultState({
70
+ draft: "",
71
+ todos: [],
72
+ saving: false,
73
+ })
74
+ .computed({
75
+ // Computeds are tracked getters with no arguments.
76
+ remainingCount() {
77
+ return this.todos.filter((todo) => !todo.done).length;
183
78
  },
184
- }),
185
- { query: "" },
186
- );
187
-
188
- new Search().query;
189
- ```
190
-
191
- ## Spread A Handle To Expose Top-Level Properties
192
-
193
- When the base state is object-shaped, spread the handle into the returned object to expose its current top-level lenses at once.
194
-
195
- ```ts
196
- import { defineManagedState, type StateHandle } from "preact-sigma";
197
-
198
- type SearchState = {
199
- page: number;
200
- query: string;
201
- };
202
-
203
- const Search = defineManagedState(
204
- (search: StateHandle<SearchState>) => ({
205
- ...search,
206
- nextPage() {
207
- search.page.set((page) => page + 1);
79
+ })
80
+ .queries({
81
+ // Queries stay reactive at the call site and can accept arguments.
82
+ visibleTodos(filter: "all" | "open" | "done") {
83
+ return this.todos.filter((todo) => {
84
+ if (filter === "open") return !todo.done;
85
+ if (filter === "done") return todo.done;
86
+ return true;
87
+ });
208
88
  },
209
- }),
210
- { page: 1, query: "" },
211
- );
212
-
213
- const search = new Search();
214
-
215
- search.query;
216
- search.page;
217
- ```
218
-
219
- ## Compose Managed States
220
-
221
- Return another managed-state instance when you want to expose it unchanged as a property.
222
- Composed managed states are available through direct property access and whole-state snapshots.
223
-
224
- ```ts
225
- import { defineManagedState, type StateHandle } from "preact-sigma";
226
-
227
- type CounterState = number;
228
-
229
- const Counter = defineManagedState(
230
- (count: StateHandle<CounterState>) => ({
231
- count,
232
- increment() {
233
- count.set((value) => value + 1);
89
+ })
90
+ .actions({
91
+ // Public state is readonly, so writes live in actions.
92
+ setDraft(draft: string) {
93
+ this.draft = draft;
234
94
  },
235
- }),
236
- 0,
237
- );
95
+ addTodo() {
96
+ if (!this.draft.trim()) return;
238
97
 
239
- type DashboardState = {
240
- ready: boolean;
241
- };
242
-
243
- const Dashboard = defineManagedState(
244
- (dashboard: StateHandle<DashboardState>) => ({
245
- dashboard,
246
- counter: new Counter(),
247
- }),
248
- { ready: false },
249
- );
250
-
251
- new Dashboard().counter.increment();
252
- ```
253
-
254
- ## Own Resources And Dispose The Instance
255
-
256
- Use `handle.own()` to register cleanup functions or disposables, and call `.dispose()` when the managed state should release them.
257
-
258
- ```ts
259
- import { defineManagedState, type StateHandle } from "preact-sigma";
260
-
261
- type PollerState = {
262
- ticks: number;
263
- };
264
-
265
- const Poller = defineManagedState(
266
- (poller: StateHandle<PollerState>) => ({
267
- ticks: poller.ticks,
268
- start() {
269
- const interval = window.setInterval(() => {
270
- poller.ticks.set((ticks) => ticks + 1);
271
- }, 1000);
272
-
273
- poller.own([() => window.clearInterval(interval)]);
274
- },
275
- }),
276
- { ticks: 0 },
277
- );
278
-
279
- const poller = new Poller();
280
-
281
- poller.start();
282
- poller.dispose();
283
- ```
284
-
285
- ## Update State
286
-
287
- Pass an Immer producer to `.set()` when your base state is object-shaped.
288
-
289
- ```ts
290
- import { defineManagedState, type StateHandle } from "preact-sigma";
291
-
292
- type SearchState = {
293
- query: string;
294
- };
295
-
296
- const Search = defineManagedState(
297
- (search: StateHandle<SearchState>) => ({
298
- setQuery(query: string) {
299
- search.set((draft) => {
300
- draft.query = query;
98
+ this.todos.push({
99
+ id: crypto.randomUUID(),
100
+ title: this.draft,
101
+ done: false,
301
102
  });
103
+ this.draft = "";
302
104
  },
303
- }),
304
- { query: "" },
305
- );
306
- ```
307
-
308
- ## Emit Events
309
-
310
- Use `.emit()` to publish a custom event with zero or one argument.
311
-
312
- ```ts
313
- import { defineManagedState, type StateHandle } from "preact-sigma";
314
-
315
- type TodoEvents = {
316
- saved: [];
317
- selected: [{ id: string }];
318
- };
319
-
320
- type TodoState = {};
321
-
322
- const Todo = defineManagedState(
323
- (todo: StateHandle<TodoState, TodoEvents>) => ({
324
- save() {
325
- todo.emit("saved");
326
- },
327
- select(id: string) {
328
- todo.emit("selected", { id });
105
+ toggleTodo(id: string) {
106
+ const todo = this.todos.find((todo) => todo.id === id);
107
+ if (todo) todo.done = !todo.done;
329
108
  },
330
- }),
331
- {},
332
- );
333
- ```
334
-
335
- ## Listen For Events
336
-
337
- Use `.on()` to subscribe to custom events from a managed state instance.
338
-
339
- ```ts
340
- const todo = new Todo();
341
-
342
- const stopSaved = todo.on("saved", () => {
343
- console.log("saved");
344
- });
345
-
346
- const stopSelected = todo.on("selected", (event) => {
347
- console.log(event.id);
348
- });
349
-
350
- stopSaved();
351
- stopSelected();
352
- ```
353
-
354
- ## Read Signals From A Managed State
355
-
356
- Use `.get(key)` for one exposed signal-backed property or `.get()` for the whole public state signal.
357
- Keyed reads do not target composed managed-state properties.
358
-
359
- ```ts
360
- const counter = new Counter();
361
-
362
- const countSignal = counter.get("count");
363
- const counterSignal = counter.get();
364
-
365
- countSignal.value;
366
- counterSignal.value.count;
367
- ```
368
-
369
- ## Peek At Public State
370
-
371
- Use `.peek(key)` for one exposed signal-backed property or `.peek()` for the whole public snapshot.
372
- Keyed peeks do not target composed managed-state properties.
373
-
374
- ```ts
375
- const counter = new Counter();
376
-
377
- counter.peek("count");
378
- counter.peek();
379
- ```
380
-
381
- ## Subscribe To Public State
382
-
383
- Use `.subscribe(key, listener)` for one exposed signal-backed property or `.subscribe(listener)` for the whole public state.
384
- Listeners receive the current value immediately and then future updates.
385
- Keyed subscriptions do not target composed managed-state properties.
109
+ async save() {
110
+ this.saving = true;
111
+ this.commit(); // Publish the loading state before awaiting.
386
112
 
387
- ```ts
388
- const counter = new Counter();
389
-
390
- const stopCount = counter.subscribe("count", (count) => {
391
- console.log(count);
392
- });
393
-
394
- const stopState = counter.subscribe((value) => {
395
- console.log(value.count);
396
- });
397
-
398
- stopCount();
399
- stopState();
400
- ```
401
-
402
- ## Use It Inside A Component
403
-
404
- Use `useManagedState()` when you want the same pattern directly inside a component.
405
-
406
- ```tsx
407
- import { useManagedState, type StateHandle } from "preact-sigma";
408
-
409
- type SearchState = {
410
- query: string;
411
- };
412
-
413
- function SearchBox() {
414
- const search = useManagedState(
415
- (search: StateHandle<SearchState>) => ({
416
- query: search.query,
417
- setQuery(query: string) {
418
- search.query.set(query);
419
- },
420
- }),
421
- () => ({ query: "" }),
422
- );
423
-
424
- return (
425
- <input value={search.query} onInput={(event) => search.setQuery(event.currentTarget.value)} />
426
- );
427
- }
428
- ```
429
-
430
- ## Subscribe In `useEffect`
431
-
432
- Use `useSubscribe()` with any subscribable source, including managed state and Preact signals.
433
- The listener receives the current value immediately and then future updates.
434
-
435
- ```tsx
436
- import { useSubscribe } from "preact-sigma";
437
-
438
- useSubscribe(counter, (value) => {
439
- console.log(value.count);
440
- });
441
- ```
113
+ await fetch("/api/todos", {
114
+ method: "POST",
115
+ body: JSON.stringify(this.todos),
116
+ });
442
117
 
443
- ## Listen To DOM Or Managed-State Events In `useEffect`
118
+ this.saving = false;
119
+ this.commit(); // Publish post-await writes explicitly.
120
+ this.emit("saved", { count: this.todos.length });
121
+ },
122
+ })
123
+ .setup(function (storageKey: string) {
124
+ // Setup is explicit and returns cleanup resources.
125
+ const interval = window.setInterval(() => {
126
+ localStorage.setItem(storageKey, JSON.stringify(this.todos));
127
+ }, 1000);
444
128
 
445
- Use `useEventTarget()` for either DOM events or managed-state events.
129
+ return [() => window.clearInterval(interval)];
130
+ });
446
131
 
447
- ```tsx
448
- import { useEventTarget } from "preact-sigma";
132
+ const todoList = new TodoList();
449
133
 
450
- useEventTarget(window, "resize", () => {
451
- console.log(window.innerWidth);
452
- });
453
- ```
134
+ // setup(...) returns one cleanup function for everything this instance owns.
135
+ const cleanup = todoList.setup("todos-demo");
454
136
 
455
- ```tsx
456
- useEventTarget(counter, "thresholdReached", (event) => {
457
- console.log(event.count);
137
+ // Queries are reactive where they are read.
138
+ const firstOpenTitle = computed(() => {
139
+ return todoList.visibleTodos("open")[0]?.title ?? "Nothing open";
458
140
  });
459
- ```
460
141
 
461
- ## Reach For Signals Helpers
462
-
463
- `batch` and `untracked` are re-exported from `@preact/signals`.
464
-
465
- ```ts
466
- import { batch, untracked } from "preact-sigma";
467
-
468
- batch(() => {
469
- counter.increment();
470
- counter.reset();
142
+ // Events are typed and unsubscribe cleanly.
143
+ const stop = todoList.on("saved", ({ count }) => {
144
+ console.log(`Saved ${count} todos`);
471
145
  });
472
146
 
473
- untracked(() => {
474
- console.log(counter.count);
475
- });
476
- ```
477
-
478
- ## Small Feature Model
479
-
480
- This pattern works well when a component or UI feature needs a small state model with a few public methods and derived values.
481
-
482
- ```ts
483
- import { defineManagedState, type StateHandle } from "preact-sigma";
147
+ todoList.setDraft("Write the README");
148
+ todoList.addTodo();
149
+ await todoList.save();
484
150
 
485
- type DialogState = boolean;
151
+ console.log(todoList.remainingCount);
152
+ console.log(firstOpenTitle.value);
486
153
 
487
- const Dialog = defineManagedState(
488
- (dialog: StateHandle<DialogState>) => ({
489
- open: dialog,
490
- show() {
491
- dialog.set(true);
492
- },
493
- hide() {
494
- dialog.set(false);
495
- },
496
- }),
497
- false,
498
- );
154
+ stop();
155
+ cleanup();
499
156
  ```
500
157
 
501
- Keep using plain `useState()` when the state is trivial.
502
-
503
- ```tsx
504
- const [open, setOpen] = useState(false);
505
- ```
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.
506
159
 
507
- ## License
160
+ ## Best Practices
508
161
 
509
- MIT. See [LICENSE-MIT](./LICENSE-MIT).
162
+ - Let `new SigmaType<TState, TEvents>()` and the builder inputs drive inference. Avoid forcing extra type arguments onto builder methods.
163
+ - Keep top-level state properties meaningful. Each top-level property gets its own signal, so shape state around the reads you want to track.
164
+ - Use `computed(...)` for argument-free derived state, and use queries for reactive reads that need parameters.
165
+ - 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.
166
+ - Use `snapshot(instance)` and `replaceState(instance, snapshot)` for committed-state replay. They work on top-level state keys and stay outside action semantics.
167
+ - 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.
168
+ - Use `setup(...)` for owned side effects, and always return cleanup resources for anything the instance starts.