@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 +201 -317
- package/dist/form/index.cjs +25 -4
- package/dist/form/index.cjs.map +1 -1
- package/dist/form/index.d.cts +9 -0
- package/dist/form/index.d.ts +9 -0
- package/dist/form/index.js +25 -4
- package/dist/form/index.js.map +1 -1
- package/dist/react/index.cjs +25 -4
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +9 -0
- package/dist/react/index.d.ts +9 -0
- package/dist/react/index.js +25 -4
- package/dist/react/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,213 +1,142 @@
|
|
|
1
|
-
# snapstate
|
|
1
|
+
# @thalesfp/snapstate
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
5
|
+
```bash
|
|
6
|
+
npm install @thalesfp/snapstate
|
|
7
|
+
```
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
## Features
|
|
8
10
|
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
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
|
-
##
|
|
19
|
+
## Quick Start
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
```ts
|
|
22
|
+
import { ReactSnapStore } from "@thalesfp/snapstate/react";
|
|
18
23
|
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
28
|
+
class TodoStore extends ReactSnapStore<State, "load"> {
|
|
29
|
+
constructor() {
|
|
30
|
+
super({ todos: [] });
|
|
31
|
+
}
|
|
56
32
|
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
|
|
37
|
+
addTodo(text: string) {
|
|
38
|
+
this.state.append("todos", { id: crypto.randomUUID(), text, done: false });
|
|
64
39
|
}
|
|
65
40
|
|
|
66
|
-
|
|
67
|
-
this.state.
|
|
41
|
+
toggle(id: string) {
|
|
42
|
+
this.state.patch("todos", (t) => t.id === id, { done: true });
|
|
68
43
|
}
|
|
69
44
|
}
|
|
70
|
-
```
|
|
71
45
|
|
|
72
|
-
|
|
46
|
+
export const todoStore = new TodoStore();
|
|
47
|
+
```
|
|
73
48
|
|
|
74
49
|
```tsx
|
|
75
|
-
function
|
|
76
|
-
return
|
|
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
|
|
80
|
-
props: (s) => ({
|
|
81
|
-
fetch: (s) => s.
|
|
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
|
-
##
|
|
70
|
+
## Entry Points
|
|
88
71
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
78
|
+
React and Zod are optional peer dependencies — only needed if you use their respective entry points.
|
|
130
79
|
|
|
131
|
-
|
|
132
|
-
// store.ts
|
|
133
|
-
import { SnapStore } from "snapstate/react";
|
|
80
|
+
## Core API — `SnapStore<T, K>`
|
|
134
81
|
|
|
135
|
-
|
|
82
|
+
Base class. `T` is the state shape, `K` is the union of async operation keys.
|
|
136
83
|
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
96
|
+
**Array:**
|
|
174
97
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
+
Options: `{ body?, headers?, onSuccess?(data)?, onError?(error)? }`
|
|
189
124
|
|
|
190
|
-
|
|
125
|
+
### Public Methods
|
|
191
126
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
135
|
+
### Custom HTTP Client
|
|
206
136
|
|
|
207
137
|
```ts
|
|
208
|
-
import { setHttpClient } from "snapstate
|
|
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
|
-
##
|
|
154
|
+
## React Integration — `ReactSnapStore<T, K>`
|
|
226
155
|
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
207
|
+
## Form Store — `SnapFormStore<V, K>`
|
|
240
208
|
|
|
241
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
251
|
+
<button type="submit">Log in</button>
|
|
293
252
|
</form>
|
|
294
253
|
);
|
|
295
254
|
}
|
|
296
255
|
|
|
297
|
-
export const LoginForm = loginStore.connect(
|
|
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
|
-
|
|
261
|
+
### Validation Modes
|
|
305
262
|
|
|
306
263
|
| Mode | Behavior |
|
|
307
264
|
|---|---|
|
|
308
265
|
| `onSubmit` | Validate only when `submit()` is called (default) |
|
|
309
|
-
| `onBlur` | Validate
|
|
310
|
-
| `onChange` | Validate
|
|
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
|
-
|
|
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
|
-
|
|
|
271
|
+
| Element | How it works |
|
|
364
272
|
|---|---|
|
|
365
|
-
|
|
|
366
|
-
| `
|
|
367
|
-
| `
|
|
368
|
-
|
|
|
369
|
-
|
|
|
370
|
-
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
| `
|
|
379
|
-
| `
|
|
380
|
-
| `
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
301
|
+
## Key Concepts
|
|
385
302
|
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
307
|
+
**Auto-batching** — Multiple synchronous `set()` calls queue a single notification via `queueMicrotask()`. Use `batch()` for explicit control.
|
|
393
308
|
|
|
394
|
-
|
|
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
|
-
|
|
311
|
+
## Example App
|
|
402
312
|
|
|
403
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
##
|
|
321
|
+
## License
|
|
436
322
|
|
|
437
|
-
|
|
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
|
package/dist/form/index.cjs
CHANGED
|
@@ -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
|
|
554
|
-
const
|
|
555
|
-
const
|
|
556
|
-
const
|
|
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;
|