preact-sigma 1.0.0 → 2.0.0

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 +113 -467
  2. package/dist/index.d.mts +207 -193
  3. package/dist/index.mjs +593 -265
  4. package/llms.txt +354 -238
  5. package/package.json +1 -1
package/README.md CHANGED
@@ -1,509 +1,155 @@
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
+ ## What It Is
8
8
 
9
- ```sh
10
- pnpm add preact-sigma
11
- ```
12
-
13
- ```sh
14
- npm install preact-sigma
15
- ```
16
-
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
89
-
90
- Return the constructor handle when you want the base state to appear as a reactive immutable property.
91
-
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;
115
-
116
- const Counter = defineManagedState(
117
- (counter: StateHandle<CounterState>) => ({
118
- doubled: computed(() => counter.get() * 2),
119
- }),
120
- 0,
121
- );
122
-
123
- new Counter().doubled;
124
- ```
9
+ At its core, `preact-sigma` lets you describe a stateful model as a constructor:
125
10
 
126
- ## Create A Tracked Query Method
11
+ - top-level state stays reactive through one signal per state property
12
+ - computed values become tracked getters
13
+ - queries become tracked methods, including queries with arguments
14
+ - actions batch reads and writes through Immer drafts
15
+ - setup handlers own side effects and cleanup
16
+ - typed events let instances notify the outside world without exposing mutable internals
127
17
 
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`.
18
+ The result feels like a small stateful object from application code, while still behaving like signal-driven state from rendering code.
131
19
 
132
- ```ts
133
- import { defineManagedState, query, type StateHandle } from "preact-sigma";
134
-
135
- type CounterState = number;
20
+ ## What You Can Do With It
136
21
 
137
- const Counter = defineManagedState(
138
- (counter: StateHandle<CounterState>) => ({
139
- isPositive: query(() => counter.get() > 0),
140
- }),
141
- 0,
142
- );
22
+ `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.
143
23
 
144
- new Counter().isPositive();
145
- ```
24
+ With it, you can:
146
25
 
147
- ## Read Base State Without Tracking
26
+ - model domain state as reusable constructors instead of one-off store objects
27
+ - read public state directly while keeping writes inside typed action methods
28
+ - derive reactive values with computed getters and parameterized queries
29
+ - publish state changes from synchronous or async actions
30
+ - observe committed state changes and optional Immer patches
31
+ - snapshot committed top-level state and replace committed state for undo-like flows
32
+ - manage timers, listeners, nested state setup, and teardown through explicit cleanup
33
+ - use the same model inside Preact components with `useSigma(...)` and `useListener(...)`
148
34
 
149
- Use `handle.peek()` when you need the current base-state snapshot without creating a reactive dependency.
35
+ ## Why This Shape Exists
150
36
 
151
- ```ts
152
- import { defineManagedState, type StateHandle } from "preact-sigma";
37
+ This package exists to keep stateful logic cohesive without giving up signal-level reactivity.
153
38
 
154
- type CounterState = number;
39
+ 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.
155
40
 
156
- const Counter = defineManagedState(
157
- (counter: StateHandle<CounterState>) => ({
158
- logNow() {
159
- console.log(counter.peek());
160
- },
161
- }),
162
- 0,
163
- );
164
- ```
165
-
166
- ## Use Top-Level Lenses
167
-
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.
41
+ ## Big Picture Example
169
42
 
170
43
  ```ts
171
- import { computed, defineManagedState, type StateHandle } from "preact-sigma";
44
+ import { computed, SigmaType } from "preact-sigma";
172
45
 
173
- type SearchState = {
174
- query: string;
46
+ type Todo = {
47
+ id: string;
48
+ title: string;
49
+ done: boolean;
175
50
  };
176
51
 
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);
52
+ const TodoList = new SigmaType<
53
+ { draft: string; todos: Todo[]; saving: boolean },
54
+ { saved: { count: number } }
55
+ >("TodoList")
56
+ .defaultState({
57
+ draft: "",
58
+ todos: [],
59
+ saving: false,
60
+ })
61
+ .computed({
62
+ // Computeds are tracked getters with no arguments.
63
+ remainingCount() {
64
+ return this.todos.filter((todo) => !todo.done).length;
183
65
  },
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);
208
- },
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);
66
+ })
67
+ .queries({
68
+ // Queries stay reactive at the call site and can accept arguments.
69
+ visibleTodos(filter: "all" | "open" | "done") {
70
+ return this.todos.filter((todo) => {
71
+ if (filter === "open") return !todo.done;
72
+ if (filter === "done") return todo.done;
73
+ return true;
74
+ });
234
75
  },
235
- }),
236
- 0,
237
- );
238
-
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)]);
76
+ })
77
+ .actions({
78
+ // Public state is readonly, so writes live in actions.
79
+ setDraft(draft: string) {
80
+ this.draft = draft;
274
81
  },
275
- }),
276
- { ticks: 0 },
277
- );
278
-
279
- const poller = new Poller();
82
+ addTodo() {
83
+ if (!this.draft.trim()) return;
280
84
 
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;
85
+ this.todos.push({
86
+ id: crypto.randomUUID(),
87
+ title: this.draft,
88
+ done: false,
301
89
  });
90
+ this.draft = "";
302
91
  },
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 });
92
+ toggleTodo(id: string) {
93
+ const todo = this.todos.find((todo) => todo.id === id);
94
+ if (todo) todo.done = !todo.done;
329
95
  },
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
- ```
96
+ async save() {
97
+ this.saving = true;
98
+ this.commit(); // Publish the loading state before awaiting.
380
99
 
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.
386
-
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
- ```
442
-
443
- ## Listen To DOM Or Managed-State Events In `useEffect`
444
-
445
- Use `useEventTarget()` for either DOM events or managed-state events.
446
-
447
- ```tsx
448
- import { useEventTarget } from "preact-sigma";
449
-
450
- useEventTarget(window, "resize", () => {
451
- console.log(window.innerWidth);
452
- });
453
- ```
100
+ await fetch("/api/todos", {
101
+ method: "POST",
102
+ body: JSON.stringify(this.todos),
103
+ });
454
104
 
455
- ```tsx
456
- useEventTarget(counter, "thresholdReached", (event) => {
457
- console.log(event.count);
458
- });
459
- ```
105
+ this.saving = false;
106
+ this.commit(); // Publish post-await writes explicitly.
107
+ this.emit("saved", { count: this.todos.length });
108
+ },
109
+ })
110
+ .setup(function (storageKey: string) {
111
+ // Setup is explicit and returns cleanup resources.
112
+ const interval = window.setInterval(() => {
113
+ localStorage.setItem(storageKey, JSON.stringify(this.todos));
114
+ }, 1000);
460
115
 
461
- ## Reach For Signals Helpers
116
+ return [() => window.clearInterval(interval)];
117
+ });
462
118
 
463
- `batch` and `untracked` are re-exported from `@preact/signals`.
119
+ const todoList = new TodoList();
464
120
 
465
- ```ts
466
- import { batch, untracked } from "preact-sigma";
121
+ // setup(...) returns one cleanup function for everything this instance owns.
122
+ const cleanup = todoList.setup("todos-demo");
467
123
 
468
- batch(() => {
469
- counter.increment();
470
- counter.reset();
124
+ // Queries are reactive where they are read.
125
+ const firstOpenTitle = computed(() => {
126
+ return todoList.visibleTodos("open")[0]?.title ?? "Nothing open";
471
127
  });
472
128
 
473
- untracked(() => {
474
- console.log(counter.count);
129
+ // Events are typed and unsubscribe cleanly.
130
+ const stop = todoList.on("saved", ({ count }) => {
131
+ console.log(`Saved ${count} todos`);
475
132
  });
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
133
 
482
- ```ts
483
- import { defineManagedState, type StateHandle } from "preact-sigma";
134
+ todoList.setDraft("Write the README");
135
+ todoList.addTodo();
136
+ await todoList.save();
484
137
 
485
- type DialogState = boolean;
138
+ console.log(todoList.remainingCount);
139
+ console.log(firstOpenTitle.value);
486
140
 
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
- );
141
+ stop();
142
+ cleanup();
499
143
  ```
500
144
 
501
- Keep using plain `useState()` when the state is trivial.
502
-
503
- ```tsx
504
- const [open, setOpen] = useState(false);
505
- ```
145
+ 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
146
 
507
- ## License
147
+ ## Best Practices
508
148
 
509
- MIT. See [LICENSE-MIT](./LICENSE-MIT).
149
+ - Let `new SigmaType<TState, TEvents>()` and the builder inputs drive inference. Avoid forcing extra type arguments onto builder methods.
150
+ - Keep top-level state properties meaningful. Each top-level property gets its own signal, so shape state around the reads you want to track.
151
+ - Use `computed(...)` for argument-free derived state, and use queries for reactive reads that need parameters.
152
+ - 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.
153
+ - Use `snapshot(instance)` and `replaceState(instance, snapshot)` for committed-state replay. They work on top-level state keys and stay outside action semantics.
154
+ - 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.
155
+ - Use `setup(...)` for owned side effects, and always return cleanup resources for anything the instance starts.