Skip to content

Commit 9176c68

Browse files
committed
chore(docs): rewrite the readme
1 parent 0db6f20 commit 9176c68

1 file changed

Lines changed: 189 additions & 58 deletions

File tree

README.md

Lines changed: 189 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,56 @@
11
# preact-sigma
22

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.
44

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:
66

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
812

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
1043

1144
```bash
1245
npm install preact-sigma
1346
```
1447

15-
## Smallest Useful Example
48+
## 30-second example
1649

1750
```ts
1851
import { SigmaType } from "preact-sigma";
1952

20-
const Counter = new SigmaType<{ count: number }>("Counter")
53+
const Counter = new SigmaType<{ count: number }>()
2154
.defaultState({
2255
count: 0,
2356
})
@@ -40,44 +73,96 @@ console.log(counter.count); // 1
4073
console.log(counter.doubled); // 2
4174
```
4275

43-
## What It Is
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(...)`
44107

45-
At its core, `preact-sigma` lets you describe a stateful model as a constructor:
108+
Use queries for reactive reads that need arguments.
46109

47-
- top-level state stays reactive through one signal per state property
48-
- computed values become tracked getters
49-
- queries become tracked methods, including queries with arguments
50-
- actions batch reads and writes through Immer drafts
51-
- setup handlers own side effects and cleanup
52-
- typed events let instances notify the outside world without exposing mutable internals
110+
```ts
111+
visibleTodos("open");
112+
```
113+
114+
Queries are for reading, not writing.
53115

54-
The result feels like a small stateful object from application code, while still behaving like signal-driven state from rendering code.
116+
### `actions(...)`
55117

56-
## What You Can Do With It
118+
Actions are where state changes happen.
57119

58-
`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.
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.
59121

60-
With it, you can:
122+
```ts
123+
.actions({
124+
rename(title: string) {
125+
this.title = title;
126+
},
127+
})
128+
```
61129

62-
- model domain state as reusable constructors instead of one-off store objects
63-
- read public state directly while keeping writes inside typed action methods
64-
- derive reactive values with computed getters and parameterized queries
65-
- publish state changes from synchronous or async actions
66-
- observe committed state changes and optional Immer patches
67-
- snapshot committed top-level state and replace committed state for undo-like flows
68-
- manage timers, listeners, nested state setup, and teardown through explicit cleanup
69-
- use the same model inside Preact components with `useSigma(...)` and `useListener(...)`
130+
### `setup(...)`
70131

71-
## Why This Shape Exists
132+
Setup is where side effects belong.
72133

73-
This package exists to keep stateful logic cohesive without giving up signal-level reactivity.
134+
Use it for things like:
74135

75-
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.
136+
- timers
137+
- event listeners
138+
- subscriptions
139+
- nested model setup
140+
- storage sync
76141

77-
## Big Picture Example
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.
143+
144+
### Events
145+
146+
Use events when the model needs to notify the outside world without exposing mutable internals.
147+
148+
Emit inside actions or setup:
78149

79150
```ts
80-
import { computed, SigmaType } from "preact-sigma";
151+
this.emit("saved", { count: 3 });
152+
```
153+
154+
Listen from the outside:
155+
156+
```ts
157+
const stop = instance.on("saved", ({ count }) => {
158+
console.log(count);
159+
});
160+
```
161+
162+
## A more realistic example
163+
164+
```ts
165+
import { SigmaType } from "preact-sigma";
81166

82167
type Todo = {
83168
id: string;
@@ -88,20 +173,18 @@ type Todo = {
88173
const TodoList = new SigmaType<
89174
{ draft: string; todos: Todo[]; saving: boolean },
90175
{ saved: { count: number } }
91-
>("TodoList")
176+
>()
92177
.defaultState({
93178
draft: "",
94179
todos: [],
95180
saving: false,
96181
})
97182
.computed({
98-
// Computeds are tracked getters with no arguments.
99183
remainingCount() {
100184
return this.todos.filter((todo) => !todo.done).length;
101185
},
102186
})
103187
.queries({
104-
// Queries stay reactive at the call site and can accept arguments.
105188
visibleTodos(filter: "all" | "open" | "done") {
106189
return this.todos.filter((todo) => {
107190
if (filter === "open") return !todo.done;
@@ -111,7 +194,6 @@ const TodoList = new SigmaType<
111194
},
112195
})
113196
.actions({
114-
// Public state is readonly, so writes live in actions.
115197
setDraft(draft: string) {
116198
this.draft = draft;
117199
},
@@ -123,6 +205,7 @@ const TodoList = new SigmaType<
123205
title: this.draft,
124206
done: false,
125207
});
208+
126209
this.draft = "";
127210
},
128211
toggleTodo(id: string) {
@@ -131,20 +214,19 @@ const TodoList = new SigmaType<
131214
},
132215
async save() {
133216
this.saving = true;
134-
this.commit(); // Publish the loading state before awaiting.
217+
this.commit(); // publish "saving" before awaiting
135218

136219
await fetch("/api/todos", {
137220
method: "POST",
138221
body: JSON.stringify(this.todos),
139222
});
140223

141224
this.saving = false;
142-
this.commit(); // Publish post-await writes explicitly.
225+
this.commit(); // publish before emitting
143226
this.emit("saved", { count: this.todos.length });
144227
},
145228
})
146229
.setup(function (storageKey: string) {
147-
// Setup is explicit and returns cleanup resources.
148230
const interval = window.setInterval(() => {
149231
localStorage.setItem(storageKey, JSON.stringify(this.todos));
150232
}, 1000);
@@ -153,44 +235,93 @@ const TodoList = new SigmaType<
153235
});
154236

155237
const todoList = new TodoList();
156-
157-
// setup(...) returns one cleanup function for everything this instance owns.
158238
const cleanup = todoList.setup("todos-demo");
159239

160-
// Queries are reactive where they are read.
161-
const firstOpenTitle = computed(() => {
162-
return todoList.visibleTodos("open")[0]?.title ?? "Nothing open";
163-
});
164-
165-
// Events are typed and unsubscribe cleanly.
166240
const stop = todoList.on("saved", ({ count }) => {
167241
console.log(`Saved ${count} todos`);
168242
});
169243

170-
todoList.setDraft("Write the README");
244+
todoList.setDraft("Rewrite the README");
171245
todoList.addTodo();
172-
await todoList.save();
173246

174247
console.log(todoList.remainingCount);
175-
console.log(firstOpenTitle.value);
248+
console.log(todoList.visibleTodos("open"));
249+
250+
await todoList.save();
176251

177252
stop();
178253
cleanup();
179254
```
180255

181-
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.
287+
288+
Use `useListener(...)` for component-scoped event subscriptions:
289+
290+
```ts
291+
import { useListener } from "preact-sigma";
292+
293+
useListener(todoList, "saved", ({ count }) => {
294+
console.log(`Saved ${count} todos`);
295+
});
296+
```
297+
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:
182310

183-
Cleanup resources can be returned as functions, `AbortController`, objects with `dispose()`, or objects with `Symbol.dispose`.
311+
- **too small for a big store abstraction**
312+
- **too stateful for a handful of loose signals**
184313

185-
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.
314+
It keeps the ergonomics of working with a model object, while preserving:
186315

187-
## Constructor and Defaults
316+
- readonly public reads
317+
- explicit write boundaries
318+
- fine-grained reactivity
319+
- explicit ownership of side effects
188320

189-
- `defaultState` values may be plain values or zero-argument initializer functions. Use initializer functions when each instance needs a fresh object, array, or class instance.
190-
- Constructor input shallowly overrides `defaultState`, so `new TodoList({ draft: "ready" })` replaces only the top-level keys you pass.
321+
That makes it useful for app state that has real behavior, not just values.
191322

192-
## More Docs
323+
## More docs
193324

194-
- [llms.txt](./llms.txt) provides the exhaustive machine-oriented API guide and terminology.
325+
- [`llms.txt`](./llms.txt) contains the exhaustive API and behavior reference.
195326
- Companion skills are available via `npx skills add alloc/preact-sigma`.
196-
- The `preact-sigma` skill packages the procedural guidance and agent-oriented workflow for this library.
327+
- The `preact-sigma` skill packages procedural guidance and agent-oriented workflow for the library.

0 commit comments

Comments
 (0)