preact-sigma 0.0.0 → 1.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.
package/README.md ADDED
@@ -0,0 +1,509 @@
1
+ # `preact-sigma`
2
+
3
+ Managed UI state for Preact Signals, with Immer-powered updates and a small public API.
4
+
5
+ For naming and API design conventions, see [best-practices.md](./best-practices.md).
6
+
7
+ ## Install
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
+ ```
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";
134
+
135
+ type CounterState = number;
136
+
137
+ const Counter = defineManagedState(
138
+ (counter: StateHandle<CounterState>) => ({
139
+ isPositive: query(() => counter.get() > 0),
140
+ }),
141
+ 0,
142
+ );
143
+
144
+ new Counter().isPositive();
145
+ ```
146
+
147
+ ## Read Base State Without Tracking
148
+
149
+ Use `handle.peek()` when you need the current base-state snapshot without creating a reactive dependency.
150
+
151
+ ```ts
152
+ import { defineManagedState, type StateHandle } from "preact-sigma";
153
+
154
+ type CounterState = number;
155
+
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.
169
+
170
+ ```ts
171
+ import { computed, defineManagedState, type StateHandle } from "preact-sigma";
172
+
173
+ type SearchState = {
174
+ query: string;
175
+ };
176
+
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);
183
+ },
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);
234
+ },
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)]);
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;
301
+ });
302
+ },
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 });
329
+ },
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.
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
+ ```
454
+
455
+ ```tsx
456
+ useEventTarget(counter, "thresholdReached", (event) => {
457
+ console.log(event.count);
458
+ });
459
+ ```
460
+
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();
471
+ });
472
+
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";
484
+
485
+ type DialogState = boolean;
486
+
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
+ );
499
+ ```
500
+
501
+ Keep using plain `useState()` when the state is trivial.
502
+
503
+ ```tsx
504
+ const [open, setOpen] = useState(false);
505
+ ```
506
+
507
+ ## License
508
+
509
+ MIT. See [LICENSE-MIT](./LICENSE-MIT).