@thalesfp/snapstate 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Thales
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,439 @@
1
+ # snapstate
2
+
3
+ Reactive state management for React. Class-based stores with a clean public API — business logic stays in the store, components stay dumb.
4
+
5
+ ## Philosophy
6
+
7
+ No more `useState` hell. No more `useEffect` spaghetti. Just stores that work.
8
+
9
+ - **Readable** — stores are plain classes with explicit methods; no magic, no hidden wiring
10
+ - **Testable** — business logic tested separately from React; stores are plain objects (call methods, assert state), no rendering or providers needed. Unit and integration tests are equally simple to write
11
+ - **Extensible** — inherit from `SnapStore`, add methods, compose stores; no middleware chains or plugin systems
12
+ - **Predictable** — state flows in one direction, updates are synchronous within a batch, and structural sharing guarantees stable references for unchanged data
13
+ - **Dumb views** — components receive props and render; business logic lives in stores, not in hooks or event handlers
14
+
15
+ ## Why?
16
+
17
+ ### Before — the `useState` + `useEffect` version
18
+
19
+ ```tsx
20
+ function UserList() {
21
+ const [users, setUsers] = useState<User[]>([]);
22
+ const [filter, setFilter] = useState<"all" | "active">("all");
23
+ const [loading, setLoading] = useState(true);
24
+ const [error, setError] = useState<string | null>(null);
25
+
26
+ useEffect(() => {
27
+ let cancelled = false;
28
+ setLoading(true);
29
+ fetch("/api/users")
30
+ .then((r) => r.json())
31
+ .then((data) => { if (!cancelled) { setUsers(data); setLoading(false); } })
32
+ .catch((e) => { if (!cancelled) { setError(e.message); setLoading(false); } });
33
+ return () => { cancelled = true; };
34
+ }, []);
35
+
36
+ const filtered = filter === "active" ? users.filter((u) => u.active) : users;
37
+
38
+ if (loading) return <p>Loading...</p>;
39
+ if (error) return <p>Error: {error}</p>;
40
+ return (
41
+ <div>
42
+ <button onClick={() => setFilter(filter === "all" ? "active" : "all")}>{filter}</button>
43
+ <ul>{filtered.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
44
+ </div>
45
+ );
46
+ }
47
+ ```
48
+
49
+ ### After — the snapstate version
50
+
51
+ **Store** (encapsulates all state + async):
52
+
53
+ ```ts
54
+ class UserStore extends SnapStore<{ users: User[]; filter: "all" | "active" }, "load"> {
55
+ constructor() { super({ users: [], filter: "all" }); }
56
+
57
+ get filtered() {
58
+ const { users, filter } = this.state.get();
59
+ return filter === "active" ? users.filter((u) => u.active) : users;
60
+ }
61
+
62
+ loadUsers() {
63
+ return this.api.get("load", "/api/users", (data) => this.state.set("users", data));
64
+ }
65
+
66
+ toggleFilter() {
67
+ this.state.set("filter", (f) => (f === "all" ? "active" : "all"));
68
+ }
69
+ }
70
+ ```
71
+
72
+ **Component** (pure function of props):
73
+
74
+ ```tsx
75
+ function UserListInner({ filtered }: { filtered: User[] }) {
76
+ return <ul>{filtered.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
77
+ }
78
+
79
+ export const UserList = userStore.connect(UserListInner, {
80
+ props: (s) => ({ filtered: s.filtered }),
81
+ fetch: (s) => s.loadUsers(),
82
+ loading: () => <p>Loading...</p>,
83
+ error: ({ error }) => <p>Error: {error}</p>,
84
+ });
85
+ ```
86
+
87
+ ## Install
88
+
89
+ ```bash
90
+ npm install snapstate
91
+ ```
92
+
93
+ ## Quick start
94
+
95
+ ```ts
96
+ import { SnapStore } from "snapstate/react";
97
+
98
+ interface Todo {
99
+ id: string;
100
+ text: string;
101
+ completed: boolean;
102
+ }
103
+
104
+ type TodoOp = "add" | "toggle";
105
+
106
+ interface TodoState {
107
+ todos: Todo[];
108
+ filter: "all" | "active" | "completed";
109
+ }
110
+
111
+ class TodoStore extends SnapStore<TodoState, TodoOp> {
112
+ constructor() {
113
+ super({ todos: [], filter: "all" });
114
+ }
115
+
116
+ addTodo(text: string) {
117
+ return this.api.post<Todo>("add", "/api/todos", {
118
+ body: { text },
119
+ onSuccess: (todo) => this.state.append("todos", todo),
120
+ });
121
+ }
122
+
123
+ toggleTodo(id: string) {
124
+ this.state.patch("todos", (t) => t.id === id, { completed: true });
125
+ }
126
+ }
127
+ ```
128
+
129
+ ## React usage
130
+
131
+ ```tsx
132
+ // store.ts
133
+ import { SnapStore } from "snapstate/react";
134
+
135
+ const todoStore = new TodoStore();
136
+
137
+ // TodoList.tsx
138
+ function TodoListInner({ todos, remaining }: { todos: Todo[]; remaining: number }) {
139
+ return (
140
+ <div>
141
+ <h2>{remaining} left</h2>
142
+ <ul>
143
+ {todos.map((t) => (
144
+ <li key={t.id} onClick={() => todoStore.toggleTodo(t.id)}>
145
+ {t.completed ? <s>{t.text}</s> : t.text}
146
+ </li>
147
+ ))}
148
+ </ul>
149
+ </div>
150
+ );
151
+ }
152
+
153
+ // connect() wires the component to the store.
154
+ // It re-renders only when the mapped props change (shallow comparison).
155
+ export const TodoList = todoStore.connect(TodoListInner, (store) => ({
156
+ todos: store.filteredTodos,
157
+ remaining: store.remaining,
158
+ }));
159
+
160
+ // connect() with async fetch, loading, and error handling.
161
+ // Calls `fetch` on mount, renders `loading` while pending, `error` on failure.
162
+ export const TodoListAsync = todoStore.connect(TodoListInner, {
163
+ props: (store) => ({
164
+ todos: store.filteredTodos,
165
+ remaining: store.remaining,
166
+ }),
167
+ fetch: (store) => store.loadTodos(),
168
+ loading: () => <p>Loading...</p>,
169
+ error: ({ error }) => <p>Failed: {error}</p>,
170
+ });
171
+ ```
172
+
173
+ ## Granular selectors
174
+
175
+ ```tsx
176
+ // connect() with granular path-based subscriptions.
177
+ // Only re-renders when the specific picked paths change.
178
+ export const UserCard = userStore.connect(UserCardInner, {
179
+ select: (pick) => ({
180
+ name: pick("user.name"),
181
+ avatar: pick("user.avatar"),
182
+ }),
183
+ });
184
+ ```
185
+
186
+ `pick(path)` reads a value at a dot-path and subscribes to that exact path. The component won't re-render when unrelated state changes (e.g. `settings.theme`).
187
+
188
+ **`props` vs `select`** — `props` receives the full store instance, so it can access computed getters and derived values. `select` only reads raw state at dot-paths, but subscribes granularly — use it when you want precise re-render control over raw state.
189
+
190
+ ## Optimistic updates
191
+
192
+ ```ts
193
+ // deleteTodo: remove immediately, rollback on API failure
194
+ deleteTodo(id: string) {
195
+ const idx = this.state.findIndexOf("todos", (t) => t.id === id);
196
+ const removed = this.state.at("todos", idx)!;
197
+ this.state.removeAt("todos", idx);
198
+
199
+ return this.api.delete("remove", `/api/todos/${id}`, {
200
+ onError: () => this.state.insertAt("todos", idx, removed),
201
+ });
202
+ }
203
+ ```
204
+
205
+ ## Custom HTTP client
206
+
207
+ ```ts
208
+ import { setHttpClient } from "snapstate/react";
209
+
210
+ // Add auth header to every request
211
+ setHttpClient({
212
+ async request(url, init) {
213
+ const res = await fetch(url, {
214
+ ...init,
215
+ headers: { ...init?.headers, Authorization: `Bearer ${getToken()}` },
216
+ body: init?.body ? JSON.stringify(init.body) : undefined,
217
+ });
218
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
219
+ const text = await res.text();
220
+ return text ? JSON.parse(text) : undefined;
221
+ },
222
+ });
223
+ ```
224
+
225
+ ## Computed values
226
+
227
+ ```ts
228
+ class TodoStore extends SnapStore<TodoState, TodoOp> {
229
+ activeTodos = this.state.computed(["todos"], (s) =>
230
+ s.todos.filter((t) => !t.completed),
231
+ );
232
+
233
+ // activeTodos.value lazily recomputes when "todos" changes
234
+ }
235
+ ```
236
+
237
+ ## Form stores
238
+
239
+ `SnapFormStore` extends `ReactSnapStore` with Zod schema validation, per-field errors, dirty tracking, and a submit lifecycle. Available from `snapstate/form`.
240
+
241
+ > Requires `zod` as a peer dependency.
242
+
243
+ ```ts
244
+ import { SnapFormStore } from "snapstate/form";
245
+ import { z } from "zod";
246
+
247
+ const schema = z.object({
248
+ email: z.string().email(),
249
+ password: z.string().min(8),
250
+ });
251
+
252
+ type LoginValues = z.infer<typeof schema>;
253
+
254
+ class LoginStore extends SnapFormStore<LoginValues, "login"> {
255
+ constructor() {
256
+ super(schema, { email: "", password: "" }, { validationMode: "onBlur" });
257
+ }
258
+
259
+ login() {
260
+ return this.submit("login", async (values) => {
261
+ await this.api.post("login", "/api/login", { body: values });
262
+ });
263
+ }
264
+ }
265
+
266
+ const loginStore = new LoginStore();
267
+ ```
268
+
269
+ ```tsx
270
+ function LoginFormInner({ values, errors, isDirty }: {
271
+ values: LoginValues;
272
+ errors: FormErrors<LoginValues>;
273
+ isDirty: boolean;
274
+ }) {
275
+ return (
276
+ <form onSubmit={(e) => { e.preventDefault(); loginStore.login(); }}>
277
+ <input
278
+ value={values.email}
279
+ onChange={(e) => loginStore.setValue("email", e.target.value)}
280
+ onBlur={() => loginStore.handleBlur("email")}
281
+ />
282
+ {errors.email && <span>{errors.email[0]}</span>}
283
+
284
+ <input
285
+ type="password"
286
+ value={values.password}
287
+ onChange={(e) => loginStore.setValue("password", e.target.value)}
288
+ onBlur={() => loginStore.handleBlur("password")}
289
+ />
290
+ {errors.password && <span>{errors.password[0]}</span>}
291
+
292
+ <button disabled={!isDirty}>Log in</button>
293
+ </form>
294
+ );
295
+ }
296
+
297
+ export const LoginForm = loginStore.connect(LoginFormInner, (s) => ({
298
+ values: s.values,
299
+ errors: s.errors,
300
+ isDirty: s.isDirty,
301
+ }));
302
+ ```
303
+
304
+ **Validation modes:**
305
+
306
+ | Mode | Behavior |
307
+ |---|---|
308
+ | `onSubmit` | Validate only when `submit()` is called (default) |
309
+ | `onBlur` | Validate a field when `handleBlur(field)` is called |
310
+ | `onChange` | Validate a field on every `setValue(field, value)` call |
311
+
312
+ ## Entry points
313
+
314
+ | Import | Description |
315
+ |---|---|
316
+ | `snapstate` | Core `SnapStore`, types, `setHttpClient` |
317
+ | `snapstate/react` | React-aware `ReactSnapStore` with `connect()` and `useSyncExternalStore` compatibility |
318
+ | `snapstate/form` | Form-aware store with Zod validation, field-level errors, dirty tracking, and submit handling |
319
+
320
+ ## API
321
+
322
+ ### `SnapStore<T, K>`
323
+
324
+ Base class. `T` is the state shape, `K` is the union of operation keys for async status tracking.
325
+
326
+ **Public methods:**
327
+
328
+ | Method | Description |
329
+ |---|---|
330
+ | `subscribe(callback)` | Subscribe to all state changes. Returns unsubscribe function. |
331
+ | `getSnapshot()` | Return a snapshot of current state. Compatible with `useSyncExternalStore`. |
332
+ | `getStatus(key)` | Get the async status (`idle` / `loading` / `ready` / `error`) of an operation. |
333
+ | `destroy()` | Tear down subscriptions and cleanup. |
334
+
335
+ **Protected — `this.state.*` (scalar):**
336
+
337
+ | Method | Description |
338
+ |---|---|
339
+ | `state.get()` | Read the full state object. |
340
+ | `state.get(path)` | Read a value at a dot-separated path (e.g. `"user.name"`). |
341
+ | `state.set(path, value)` | Set a value at `path`. Accepts a value or updater `(prev) => next`. |
342
+ | `state.batch(fn)` | Group multiple `state.set` calls into a single notification flush. |
343
+ | `state.computed(deps, fn)` | Create a lazily-recomputed derived value from dependency paths. |
344
+
345
+ **Protected — `this.state.*` (array):**
346
+
347
+ | Method | Description |
348
+ |---|---|
349
+ | `state.append(path, ...items)` | Append items to end of array. |
350
+ | `state.prepend(path, ...items)` | Add items to start of array. |
351
+ | `state.insertAt(path, index, ...items)` | Insert items at a specific index. |
352
+ | `state.patch(path, predicate, updates)` | Shallow-merge updates into all matching items. |
353
+ | `state.remove(path, predicate)` | Remove all items matching predicate. |
354
+ | `state.removeAt(path, index)` | Remove item at index. Supports negative indices. |
355
+ | `state.at(path, index)` | Get item at index. Supports negative indices. |
356
+ | `state.filter(path, predicate)` | Return all matching items. |
357
+ | `state.find(path, predicate)` | Return first matching item. |
358
+ | `state.findIndexOf(path, predicate)` | Return index of first match, or -1. |
359
+ | `state.count(path, predicate)` | Count matching items. |
360
+
361
+ **Protected — `this.api.*`:**
362
+
363
+ | Method | Description |
364
+ |---|---|
365
+ | `api.fetch(key, fn)` | Run an async function with tracked loading/error status. |
366
+ | `api.get(key, url, onSuccess?)` | GET request with status tracking. |
367
+ | `api.post(key, url, options?)` | POST request with status tracking. |
368
+ | `api.put(key, url, options?)` | PUT request with status tracking. |
369
+ | `api.patch(key, url, options?)` | PATCH request with status tracking. |
370
+ | `api.delete(key, url, options?)` | DELETE request with status tracking. |
371
+
372
+ ### `ReactSnapStore<T, K>`
373
+
374
+ Extends `SnapStore` with React integration. Available from `snapstate/react`.
375
+
376
+ | Method | Description |
377
+ |---|---|
378
+ | `connect(Component, mapToProps)` | Wire a component to the store, injecting derived props. |
379
+ | `connect(Component, config)` | Wire with async data fetching, loading, and error states. |
380
+ | `connect(Component, { select })` | Wire with granular path-based subscriptions via `pick(path)`. |
381
+
382
+ ### `SnapFormStore<V, K>`
383
+
384
+ Extends `ReactSnapStore` with form handling. Available from `snapstate/form`. `V` is the form values shape, `K` is the union of operation keys.
385
+
386
+ Constructor: `new SnapFormStore(schema, initialValues, config?)`
387
+
388
+ - `schema` — a Zod schema used for validation
389
+ - `initialValues` — starting values for the form
390
+ - `config.validationMode` — `"onSubmit"` (default), `"onBlur"`, or `"onChange"`
391
+
392
+ **Public getters:**
393
+
394
+ | Getter | Type | Description |
395
+ |---|---|---|
396
+ | `values` | `V` | Current form values |
397
+ | `errors` | `FormErrors<V>` | Validation errors keyed by field (`{ [field]: string[] }`) |
398
+ | `isDirty` | `boolean` | Whether any value differs from initial values |
399
+ | `isValid` | `boolean` | Whether the form has no errors |
400
+
401
+ **Public methods:**
402
+
403
+ | Method | Description |
404
+ |---|---|
405
+ | `setValue(field, value)` | Set a field value. Triggers validation in `onChange` mode. |
406
+ | `handleBlur(field)` | Call on field blur. Triggers validation in `onBlur` mode. |
407
+ | `isFieldDirty(field)` | Check if a specific field differs from its initial value. |
408
+ | `setError(field, message)` | Manually add an error message to a field. |
409
+ | `clearErrors()` | Clear all validation errors. |
410
+ | `validate()` | Validate the full form. Returns parsed data or `null`. |
411
+ | `validateField(field)` | Validate a single field and update errors. |
412
+ | `reset()` | Reset values to initial state and clear errors. |
413
+ | `clear()` | Clear all values to type-appropriate zero-values and reset errors. |
414
+ | `setInitialValues(values)` | Update initial values and sync current values. |
415
+ | `submit(key, handler)` | Validate, then call `handler(values)` with tracked async status. Returns `undefined` if validation fails. |
416
+
417
+ Inherits `connect()`, `subscribe()`, `getSnapshot()`, `getStatus()`, and `destroy()` from `ReactSnapStore`.
418
+
419
+ **Types:**
420
+
421
+ | Type | Description |
422
+ |---|---|
423
+ | `FormState<V>` | Internal state shape: `{ values, initial, errors, submitStatus }` |
424
+ | `FormErrors<V>` | `{ [K in keyof V]?: string[] }` — field-level error messages |
425
+ | `ValidationMode` | `"onSubmit" \| "onBlur" \| "onChange"` |
426
+
427
+ ### `setHttpClient(client)`
428
+
429
+ Replace the global HTTP client used by `api.get` and `api.post/put/patch/delete`. The client must implement `request<R>(url, init?) => Promise<R>`.
430
+
431
+ ### `ApiRequestOptions<R>`
432
+
433
+ Options for HTTP verb methods: `body`, `headers`, `onSuccess(data)`, `onError(error)`.
434
+
435
+ ## Architecture
436
+
437
+ - **Path-based subscriptions** via a trie structure - listeners only fire when their path (or ancestors/descendants) change
438
+ - **Auto-batching** - synchronous sets are coalesced via microtask by default
439
+ - **Structural sharing** - `state.set` produces new references only along the updated path