@thalesfp/snapstate 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,213 +1,142 @@
1
- # snapstate
1
+ # @thalesfp/snapstate
2
2
 
3
- Reactive state management for React. Class-based stores with a clean public API business logic stays in the store, components stay dumb.
3
+ State management for React. Replace useState/useEffect tangles with class-based stores that are easy to test, easy to extend, and predictable by default.
4
4
 
5
- ## Philosophy
5
+ ```bash
6
+ npm install @thalesfp/snapstate
7
+ ```
6
8
 
7
- No more `useState` hell. No more `useEffect` spaghetti. Just stores that work.
9
+ ## Features
8
10
 
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
11
+ - **Class-based stores** — business logic in stores, components stay dumb
12
+ - **Path-based subscriptions** — listeners fire only when relevant paths change
13
+ - **Structural sharing** — unchanged subtrees keep reference identity
14
+ - **Auto-batching** — synchronous sets coalesce into a single notification
15
+ - **Built-in HTTP** — pluggable client with loading/error status tracking
16
+ - **React integration** — `connect()` HOC with `useSyncExternalStore` under the hood
17
+ - **Form stores** — Zod validation, `register()` for all HTML input types, dirty tracking, submit lifecycle
14
18
 
15
- ## Why?
19
+ ## Quick Start
16
20
 
17
- ### Before — the `useState` + `useEffect` version
21
+ ```ts
22
+ import { ReactSnapStore } from "@thalesfp/snapstate/react";
18
23
 
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
- );
24
+ interface State {
25
+ todos: { id: string; text: string; done: boolean }[];
46
26
  }
47
- ```
48
-
49
- ### After — the snapstate version
50
-
51
- **Store** (encapsulates all state + async):
52
27
 
53
- ```ts
54
- class UserStore extends SnapStore<{ users: User[]; filter: "all" | "active" }, "load"> {
55
- constructor() { super({ users: [], filter: "all" }); }
28
+ class TodoStore extends ReactSnapStore<State, "load"> {
29
+ constructor() {
30
+ super({ todos: [] });
31
+ }
56
32
 
57
- get filtered() {
58
- const { users, filter } = this.state.get();
59
- return filter === "active" ? users.filter((u) => u.active) : users;
33
+ loadTodos() {
34
+ return this.api.get("load", "/api/todos", (data) => this.state.set("todos", data));
60
35
  }
61
36
 
62
- loadUsers() {
63
- return this.api.get("load", "/api/users", (data) => this.state.set("users", data));
37
+ addTodo(text: string) {
38
+ this.state.append("todos", { id: crypto.randomUUID(), text, done: false });
64
39
  }
65
40
 
66
- toggleFilter() {
67
- this.state.set("filter", (f) => (f === "all" ? "active" : "all"));
41
+ toggle(id: string) {
42
+ this.state.patch("todos", (t) => t.id === id, { done: true });
68
43
  }
69
44
  }
70
- ```
71
45
 
72
- **Component** (pure function of props):
46
+ export const todoStore = new TodoStore();
47
+ ```
73
48
 
74
49
  ```tsx
75
- function UserListInner({ filtered }: { filtered: User[] }) {
76
- return <ul>{filtered.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
50
+ function TodoListView({ todos }: { todos: State["todos"] }) {
51
+ return (
52
+ <ul>
53
+ {todos.map((t) => (
54
+ <li key={t.id} onClick={() => todoStore.toggle(t.id)}>
55
+ {t.done ? <s>{t.text}</s> : t.text}
56
+ </li>
57
+ ))}
58
+ </ul>
59
+ );
77
60
  }
78
61
 
79
- export const UserList = userStore.connect(UserListInner, {
80
- props: (s) => ({ filtered: s.filtered }),
81
- fetch: (s) => s.loadUsers(),
62
+ export const TodoList = todoStore.connect(TodoListView, {
63
+ props: (s) => ({ todos: s.state.get("todos") }),
64
+ fetch: (s) => s.loadTodos(),
82
65
  loading: () => <p>Loading...</p>,
83
66
  error: ({ error }) => <p>Error: {error}</p>,
84
67
  });
85
68
  ```
86
69
 
87
- ## Install
70
+ ## Entry Points
88
71
 
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
- ```
72
+ | Import | Description |
73
+ |---|---|
74
+ | `@thalesfp/snapstate` | Core `SnapStore`, types, `setHttpClient` |
75
+ | `@thalesfp/snapstate/react` | `ReactSnapStore` with `connect()` HOC |
76
+ | `@thalesfp/snapstate/form` | `SnapFormStore` with Zod validation and form lifecycle |
128
77
 
129
- ## React usage
78
+ React and Zod are optional peer dependencies — only needed if you use their respective entry points.
130
79
 
131
- ```tsx
132
- // store.ts
133
- import { SnapStore } from "snapstate/react";
80
+ ## Core API — `SnapStore<T, K>`
134
81
 
135
- const todoStore = new TodoStore();
82
+ Base class. `T` is the state shape, `K` is the union of async operation keys.
136
83
 
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
- }
84
+ ### State Methods (protected `this.state.*`)
152
85
 
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
- }));
86
+ **Scalar:**
159
87
 
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
- ```
88
+ | Method | Description |
89
+ |---|---|
90
+ | `get()` | Read the full state object |
91
+ | `get(path)` | Read a value at a dot-path (e.g. `"user.name"`) |
92
+ | `set(path, value)` | Set a value. Accepts a value or updater `(prev) => next` |
93
+ | `batch(fn)` | Group multiple sets into a single notification |
94
+ | `computed(deps, fn)` | Lazily-recomputed derived value from dependency paths |
172
95
 
173
- ## Granular selectors
96
+ **Array:**
174
97
 
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
- ```
98
+ | Method | Description |
99
+ |---|---|
100
+ | `append(path, ...items)` | Add items to end |
101
+ | `prepend(path, ...items)` | Add items to start |
102
+ | `insertAt(path, index, ...items)` | Insert at index |
103
+ | `patch(path, predicate, updates)` | Shallow-merge into matching items |
104
+ | `remove(path, predicate)` | Remove matching items |
105
+ | `removeAt(path, index)` | Remove at index (supports negative) |
106
+ | `at(path, index)` | Get item at index (supports negative) |
107
+ | `filter(path, predicate)` | Return matching items |
108
+ | `find(path, predicate)` | Return first match |
109
+ | `findIndexOf(path, predicate)` | Index of first match, or -1 |
110
+ | `count(path, predicate)` | Count matching items |
111
+
112
+ ### HTTP Methods (protected `this.api.*`)
185
113
 
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`).
114
+ | Method | Description |
115
+ |---|---|
116
+ | `fetch(key, fn)` | Run async function with tracked status |
117
+ | `get(key, url, onSuccess?)` | GET with status tracking |
118
+ | `post(key, url, options?)` | POST with status tracking |
119
+ | `put(key, url, options?)` | PUT with status tracking |
120
+ | `patch(key, url, options?)` | PATCH with status tracking |
121
+ | `delete(key, url, options?)` | DELETE with status tracking |
187
122
 
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.
123
+ Options: `{ body?, headers?, onSuccess?(data)?, onError?(error)? }`
189
124
 
190
- ## Optimistic updates
125
+ ### Public Methods
191
126
 
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
- ```
127
+ | Method | Description |
128
+ |---|---|
129
+ | `subscribe(callback)` | Subscribe to all changes. Returns unsubscribe function |
130
+ | `subscribe(path, callback)` | Subscribe to a specific path |
131
+ | `getSnapshot()` | Current state (compatible with `useSyncExternalStore`) |
132
+ | `getStatus(key)` | Async status: `{ status: AsyncStatus, error: string \| null }` |
133
+ | `destroy()` | Tear down subscriptions |
204
134
 
205
- ## Custom HTTP client
135
+ ### Custom HTTP Client
206
136
 
207
137
  ```ts
208
- import { setHttpClient } from "snapstate/react";
138
+ import { setHttpClient } from "@thalesfp/snapstate";
209
139
 
210
- // Add auth header to every request
211
140
  setHttpClient({
212
141
  async request(url, init) {
213
142
  const res = await fetch(url, {
@@ -222,26 +151,65 @@ setHttpClient({
222
151
  });
223
152
  ```
224
153
 
225
- ## Computed values
154
+ ## React Integration — `ReactSnapStore<T, K>`
226
155
 
227
- ```ts
228
- class TodoStore extends SnapStore<TodoState, TodoOp> {
229
- activeTodos = this.state.computed(["todos"], (s) =>
230
- s.todos.filter((t) => !t.completed),
231
- );
156
+ Extends `SnapStore`. Available from `@thalesfp/snapstate/react`.
232
157
 
233
- // activeTodos.value lazily recomputes when "todos" changes
234
- }
158
+ ### connect()
159
+
160
+ **Simple** — map store to props:
161
+
162
+ ```tsx
163
+ const UserName = userStore.connect(
164
+ ({ name }: { name: string }) => <span>{name}</span>,
165
+ (store) => ({ name: store.state.get("user.name") }),
166
+ );
167
+ ```
168
+
169
+ **Advanced** — with data fetching:
170
+
171
+ ```tsx
172
+ const UserProfile = userStore.connect(ProfileView, {
173
+ props: (s) => ({ user: s.state.get("user") }),
174
+ fetch: (s) => s.loadUser(),
175
+ loading: () => <Skeleton />,
176
+ error: ({ error }) => <p>{error}</p>,
177
+ });
178
+ ```
179
+
180
+ **Granular** — path-based subscriptions:
181
+
182
+ ```tsx
183
+ const UserCard = userStore.connect(CardView, {
184
+ select: (pick) => ({
185
+ name: pick("user.name"),
186
+ avatar: pick("user.avatar"),
187
+ }),
188
+ });
189
+ ```
190
+
191
+ `pick(path)` subscribes to that exact path — the component only re-renders when those specific values change.
192
+
193
+ **Setup and cleanup** — lifecycle hooks that pair with `fetch`:
194
+
195
+ ```tsx
196
+ const Dashboard = dashboardStore.connect(DashboardView, {
197
+ props: (s) => ({ stats: s.state.get("stats") }),
198
+ setup: (s) => s.initPolling(),
199
+ fetch: (s) => s.loadStats(),
200
+ cleanup: (s) => s.stopPolling(),
201
+ loading: () => <Skeleton />,
202
+ });
235
203
  ```
236
204
 
237
- ## Form stores
205
+ `setup` runs synchronously before `fetch` — use it to initialize timers, subscriptions, or AbortControllers. `cleanup` fires once on unmount. Both work with or without `fetch` and are safe in React StrictMode.
238
206
 
239
- `SnapFormStore` extends `ReactSnapStore` with Zod schema validation, per-field errors, dirty tracking, and a submit lifecycle. Available from `snapstate/form`.
207
+ ## Form Store `SnapFormStore<V, K>`
240
208
 
241
- > Requires `zod` as a peer dependency.
209
+ Extends `ReactSnapStore`. Available from `@thalesfp/snapstate/form`. Requires `zod` peer dependency.
242
210
 
243
211
  ```ts
244
- import { SnapFormStore } from "snapstate/form";
212
+ import { SnapFormStore } from "@thalesfp/snapstate/form";
245
213
  import { z } from "zod";
246
214
 
247
215
  const schema = z.object({
@@ -262,178 +230,94 @@ class LoginStore extends SnapFormStore<LoginValues, "login"> {
262
230
  });
263
231
  }
264
232
  }
265
-
266
- const loginStore = new LoginStore();
267
233
  ```
268
234
 
235
+ ### Using register()
236
+
237
+ `register()` returns props to spread onto form elements — handles ref tracking, value sync, and event binding:
238
+
269
239
  ```tsx
270
- function LoginFormInner({ values, errors, isDirty }: {
271
- values: LoginValues;
272
- errors: FormErrors<LoginValues>;
273
- isDirty: boolean;
274
- }) {
240
+ const loginStore = new LoginStore();
241
+
242
+ function LoginFormView({ errors }: { errors: FormErrors<LoginValues> }) {
275
243
  return (
276
244
  <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
- />
245
+ <input {...loginStore.register("email")} />
282
246
  {errors.email && <span>{errors.email[0]}</span>}
283
247
 
284
- <input
285
- type="password"
286
- value={values.password}
287
- onChange={(e) => loginStore.setValue("password", e.target.value)}
288
- onBlur={() => loginStore.handleBlur("password")}
289
- />
248
+ <input type="password" {...loginStore.register("password")} />
290
249
  {errors.password && <span>{errors.password[0]}</span>}
291
250
 
292
- <button disabled={!isDirty}>Log in</button>
251
+ <button type="submit">Log in</button>
293
252
  </form>
294
253
  );
295
254
  }
296
255
 
297
- export const LoginForm = loginStore.connect(LoginFormInner, (s) => ({
298
- values: s.values,
256
+ export const LoginForm = loginStore.connect(LoginFormView, (s) => ({
299
257
  errors: s.errors,
300
- isDirty: s.isDirty,
301
258
  }));
302
259
  ```
303
260
 
304
- **Validation modes:**
261
+ ### Validation Modes
305
262
 
306
263
  | Mode | Behavior |
307
264
  |---|---|
308
265
  | `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):**
266
+ | `onBlur` | Validate field on blur |
267
+ | `onChange` | Validate field on every change |
346
268
 
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.*`:**
269
+ ### Supported Form Elements
362
270
 
363
- | Method | Description |
271
+ | Element | How it works |
364
272
  |---|---|
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`.
273
+ | `<input type="text">` (and password, email, url, tel, search) | `el.value` read/write |
274
+ | `<input type="number">` | Coerced via `Number()` when field type is `number` |
275
+ | `<input type="checkbox">` | `el.checked` / `defaultChecked` for boolean fields |
276
+ | `<textarea>` | `el.value` read/write |
277
+ | `<select>` | `el.value` read/write |
278
+ | `<input type="range">` | Number coercion; browser handles clamping |
279
+ | `<input type="radio">` | Multiple elements per field; reads checked value |
280
+ | `<input type="date">` / `time` / `datetime-local` | Coerced to `Date`; formatted for DOM |
281
+ | `<select multiple>` | Reads `selectedOptions` as array; coerces item types |
282
+ | `<input type="file">` | Returns `File` or `File[]`; reset clears selection |
283
+
284
+ ### Form Methods
375
285
 
376
286
  | Method | Description |
377
287
  |---|---|
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>`
288
+ | `register(field)` | Returns `{ ref, name, defaultValue, onBlur, onChange }` for form elements |
289
+ | `setValue(field, value)` | Set field value |
290
+ | `getValue(field)` | Get current field value (reads from DOM ref if registered) |
291
+ | `getValues()` | Get all current values |
292
+ | `validate()` | Validate full form, returns parsed data or `null` |
293
+ | `validateField(field)` | Validate single field |
294
+ | `submit(key, handler)` | Validate then call handler with async status tracking |
295
+ | `reset()` | Reset to initial values |
296
+ | `clear()` | Clear to type-appropriate zero values |
297
+ | `setInitialValues(values)` | Update initial values |
298
+ | `isDirty` / `isFieldDirty(field)` | Dirty tracking (supports Date and array equality) |
299
+ | `errors` / `isValid` | Field-level error arrays |
383
300
 
384
- Extends `ReactSnapStore` with form handling. Available from `snapstate/form`. `V` is the form values shape, `K` is the union of operation keys.
301
+ ## Key Concepts
385
302
 
386
- Constructor: `new SnapFormStore(schema, initialValues, config?)`
303
+ **Path-based subscriptions** — State changes are tracked via dot-separated paths (e.g. `"user.name"`, `"items.0.title"`). A trie structure ensures listeners fire only when their path or its ancestors/descendants change.
387
304
 
388
- - `schema` a Zod schema used for validation
389
- - `initialValues` — starting values for the form
390
- - `config.validationMode` — `"onSubmit"` (default), `"onBlur"`, or `"onChange"`
305
+ **Structural sharing** — Every `set()` produces a new root object but preserves reference identity for unchanged subtrees. This makes React's shallow comparison efficient.
391
306
 
392
- **Public getters:**
307
+ **Auto-batching** — Multiple synchronous `set()` calls queue a single notification via `queueMicrotask()`. Use `batch()` for explicit control.
393
308
 
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 |
309
+ **Async status tracking** Every `api.*` call is keyed. `getStatus(key)` returns `{ status, error }` where status has boolean flags: `isIdle`, `isLoading`, `isReady`, `isError`.
400
310
 
401
- **Public methods:**
311
+ ## Example App
402
312
 
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)`
313
+ A full Vite + React 19 demo lives in [`example/`](./example/) with todos, auth, and account profile features.
428
314
 
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)`.
315
+ ```bash
316
+ npm run build # Build library first
317
+ cd example && npm install # Install example deps
318
+ npm run example:dev # Start dev server
319
+ ```
434
320
 
435
- ## Architecture
321
+ ## License
436
322
 
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
323
+ MIT
@@ -550,10 +550,13 @@ var ReactSnapStore = class extends SnapStore {
550
550
  if (typeof configOrMapper === "object" && "select" in configOrMapper) {
551
551
  return this._connectWithSelect(Component, configOrMapper.select);
552
552
  }
553
- const mapToProps = typeof configOrMapper === "function" ? configOrMapper : configOrMapper.props;
554
- const fetchFn = typeof configOrMapper === "function" ? void 0 : configOrMapper.fetch;
555
- const loadingComponent = typeof configOrMapper === "function" ? void 0 : configOrMapper.loading;
556
- const errorComponent = typeof configOrMapper === "function" ? void 0 : configOrMapper.error;
553
+ const config = typeof configOrMapper === "function" ? null : configOrMapper;
554
+ const mapToProps = config ? config.props : configOrMapper;
555
+ const fetchFn = config?.fetch;
556
+ const loadingComponent = config?.loading;
557
+ const errorComponent = config?.error;
558
+ const setupFn = config?.setup;
559
+ const cleanupFn = config?.cleanup;
557
560
  const Connected = (0, import_react.forwardRef)(function Connected2(ownProps, ref) {
558
561
  const cachedRef = (0, import_react.useRef)(null);
559
562
  const revisionRef = (0, import_react.useRef)(0);
@@ -580,6 +583,24 @@ var ReactSnapStore = class extends SnapStore {
580
583
  const mappedProps = (0, import_react.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
581
584
  const [asyncState, setAsyncState] = (0, import_react.useState)({ status: asyncStatus("idle"), error: null });
582
585
  const fetchGenRef = (0, import_react.useRef)(0);
586
+ const lifecycleGenRef = (0, import_react.useRef)(0);
587
+ (0, import_react.useEffect)(() => {
588
+ if (!setupFn && !cleanupFn) return;
589
+ const gen = ++lifecycleGenRef.current;
590
+ if (setupFn) {
591
+ queueMicrotask(() => {
592
+ if (gen === lifecycleGenRef.current) setupFn(store);
593
+ });
594
+ }
595
+ return () => {
596
+ const teardownGen = lifecycleGenRef.current;
597
+ if (cleanupFn) {
598
+ queueMicrotask(() => {
599
+ if (teardownGen === lifecycleGenRef.current) cleanupFn(store);
600
+ });
601
+ }
602
+ };
603
+ }, []);
583
604
  (0, import_react.useEffect)(() => {
584
605
  if (!fetchFn) {
585
606
  return;