@wildgrowth/voltix 1.0.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/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/chunk-HGGX55FR.mjs +1 -0
- package/dist/eslint.d.mts +29 -0
- package/dist/eslint.d.ts +29 -0
- package/dist/eslint.js +1 -0
- package/dist/eslint.mjs +1 -0
- package/dist/index.d.mts +373 -0
- package/dist/index.d.ts +373 -0
- package/dist/index.js +4 -0
- package/dist/index.mjs +4 -0
- package/docs/api-reference.md +804 -0
- package/docs/comparison.md +64 -0
- package/docs/composition.md +248 -0
- package/docs/core-concepts.md +145 -0
- package/docs/getting-started.md +110 -0
- package/docs/guides.md +479 -0
- package/docs/patterns.md +291 -0
- package/docs/walkthrough.md +334 -0
- package/package.json +94 -0
package/docs/patterns.md
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# Patterns
|
|
2
|
+
|
|
3
|
+
Complete recipes you can copy into an app.
|
|
4
|
+
|
|
5
|
+
- [Sending requests](#sending-requests)
|
|
6
|
+
- [Fetching by a parameter](#fetching-by-a-parameter)
|
|
7
|
+
- [Mutations with optimistic updates](#mutations-with-optimistic-updates)
|
|
8
|
+
- [Live updates with polling](#live-updates-with-polling)
|
|
9
|
+
- [A live table: a lookup + polling](#a-live-table-a-lookup--polling)
|
|
10
|
+
|
|
11
|
+
See also [Composition](./composition.md) for lookups and nesting, [Guides](./guides.md) for the feature-level reference, and [Core concepts](./core-concepts.md) for how re-rendering works.
|
|
12
|
+
|
|
13
|
+
## Sending requests
|
|
14
|
+
|
|
15
|
+
Keep a request in the store as three keys: `status`, `data`, `error`. The spinner reads `status`, the table reads `data`, and each re-renders only when its own key changes.
|
|
16
|
+
|
|
17
|
+
Put the fetch in a function next to the store. `set` writes several keys in one update, so a component reading two of them re-renders once.
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { createStore } from '@wildgrowth/voltix';
|
|
21
|
+
|
|
22
|
+
type User = { id: string; name: string };
|
|
23
|
+
type Status = 'idle' | 'loading' | 'success' | 'error';
|
|
24
|
+
|
|
25
|
+
export const users = createStore({
|
|
26
|
+
status: 'idle' as Status,
|
|
27
|
+
data: [] as User[],
|
|
28
|
+
error: null as string | null,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export async function loadUsers() {
|
|
32
|
+
users.setKey('status', 'loading');
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch('/api/users');
|
|
35
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
36
|
+
const data: User[] = await res.json();
|
|
37
|
+
users.set({ data, error: null, status: 'success' });
|
|
38
|
+
} catch (err) {
|
|
39
|
+
users.set({ error: (err as Error).message, status: 'error' });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
In components, split the reads: the shell reads `status`, the list reads `data`. Refetching a fresh list never re-runs the spinner branch.
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
import { useStoreKey, useStoreSelector } from '@wildgrowth/voltix';
|
|
48
|
+
import { users, loadUsers } from './users';
|
|
49
|
+
|
|
50
|
+
function UserList() {
|
|
51
|
+
const { status, error } = useStoreSelector(users, ['status', 'error']);
|
|
52
|
+
if (status === 'loading') return <Spinner />;
|
|
53
|
+
if (status === 'error') return <Error message={error} onRetry={loadUsers} />;
|
|
54
|
+
return <Rows />;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function Rows() {
|
|
58
|
+
const data = useStoreKey(users, 'data'); // re-renders only when the list changes
|
|
59
|
+
return <ul>{data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Start the load as early as you can. An effect in a leaf component fires only after everything above it has rendered and mounted, so every level you move the call up starts the request sooner. The store lives outside React, so the earliest spot is outside React entirely — your entry file — where the fetch runs in parallel with the first render:
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
// main.tsx — the fetch races the initial render
|
|
67
|
+
loadUsers();
|
|
68
|
+
createRoot(document.getElementById('root')!).render(<App />);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
In an SSR framework, module scope and the entry file run on the server too, so start client loads from the top component's effect instead — or pass server-fetched data down as props, see [server rendering](./guides.md#server-rendering). For data only some screens need, put the kick-off in the highest component that knows it's needed:
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
function useUsers() {
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (users.getKey('status') === 'idle') loadUsers();
|
|
77
|
+
}, []);
|
|
78
|
+
return useStoreSelector(users, ['status', 'data', 'error']);
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Because the store lives outside React, mounting two `UserList` components does not fetch twice: the `idle` guard runs once, and both components read the same slice.
|
|
83
|
+
|
|
84
|
+
## Fetching by a parameter
|
|
85
|
+
|
|
86
|
+
When the request depends on an input (a selected id, a search query), keep the input in the store and fetch when it changes. Before writing the response, check the store still wants it — switch documents quickly and a slow old response can land after a newer one:
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
import { createStore } from '@wildgrowth/voltix';
|
|
90
|
+
|
|
91
|
+
type Doc = { id: string; title: string; body: string };
|
|
92
|
+
|
|
93
|
+
export const viewer = createStore({
|
|
94
|
+
id: null as string | null,
|
|
95
|
+
status: 'idle' as 'idle' | 'loading' | 'success' | 'error',
|
|
96
|
+
data: null as Doc | null,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export async function openDocument(id: string) {
|
|
100
|
+
if (viewer.getKey('id') === id) return;
|
|
101
|
+
viewer.set({ id, status: 'loading' });
|
|
102
|
+
const res = await fetch(`/api/documents/${id}`);
|
|
103
|
+
const data: Doc = await res.json();
|
|
104
|
+
if (viewer.getKey('id') !== id) return; // the user opened another document; drop this one
|
|
105
|
+
viewer.set({ data, status: 'success' });
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The guard is plain store usage: the store holds the current selection, so reading it answers "is this response still the one on screen?". If a fetching library (React Query, SWR) handles your requests, it owns this problem — write its results into the store and skip the guard.
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
function DocumentView({ id }: { id: string }) {
|
|
113
|
+
useEffect(() => { openDocument(id); }, [id]);
|
|
114
|
+
const { status, data } = useStoreSelector(viewer, ['status', 'data']);
|
|
115
|
+
return status === 'success' && data ? <article>{data.body}</article> : <Spinner />;
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Mutations with optimistic updates
|
|
120
|
+
|
|
121
|
+
Write the change immediately, then tell the server. Snapshot the old value first so you can roll back if the request fails.
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
export const settings = createStore({ theme: 'light' as 'light' | 'dark', saving: false });
|
|
125
|
+
|
|
126
|
+
export async function setTheme(theme: 'light' | 'dark') {
|
|
127
|
+
const prev = settings.getKey('theme');
|
|
128
|
+
settings.set({ theme, saving: true }); // optimistic: UI updates now
|
|
129
|
+
try {
|
|
130
|
+
const res = await fetch('/api/settings', {
|
|
131
|
+
method: 'PATCH',
|
|
132
|
+
body: JSON.stringify({ theme }),
|
|
133
|
+
});
|
|
134
|
+
if (!res.ok) throw new Error('save failed');
|
|
135
|
+
settings.setKey('saving', false);
|
|
136
|
+
} catch {
|
|
137
|
+
settings.set({ theme: prev, saving: false }); // roll back to the snapshot
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
For a list, snapshot the array, update it optimistically, and restore the snapshot on failure.
|
|
143
|
+
|
|
144
|
+
## Live updates with polling
|
|
145
|
+
|
|
146
|
+
For data that changes on the server, load a full snapshot once, then poll for what changed. The server returns a cursor with every response; send it back as `?since=` and you get only the rows that moved since. Payloads stay small no matter how long the tab is open.
|
|
147
|
+
|
|
148
|
+
The store holds the rows by id, their order, and the current cursor.
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
import { createStore } from '@wildgrowth/voltix';
|
|
152
|
+
|
|
153
|
+
type Order = { id: string; total: number; status: string };
|
|
154
|
+
|
|
155
|
+
type Snapshot = { orders: Order[]; cursor: string };
|
|
156
|
+
type Delta = { changed: Order[]; removed: string[]; cursor: string };
|
|
157
|
+
|
|
158
|
+
export const orders = createStore({
|
|
159
|
+
byId: {} as Record<string, Order>,
|
|
160
|
+
ids: new Set<string>(),
|
|
161
|
+
cursor: null as string | null,
|
|
162
|
+
status: 'idle' as 'idle' | 'ready' | 'error',
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
export async function bootstrapOrders() {
|
|
166
|
+
const res = await fetch('/api/orders');
|
|
167
|
+
const snap: Snapshot = await res.json();
|
|
168
|
+
const byId: Record<string, Order> = {};
|
|
169
|
+
for (const o of snap.orders) byId[o.id] = o;
|
|
170
|
+
orders.set({ byId, ids: new Set(snap.orders.map((o) => o.id)), cursor: snap.cursor, status: 'ready' });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function applyOrdersDelta(delta: Delta) {
|
|
174
|
+
if (delta.changed.length === 0 && delta.removed.length === 0) {
|
|
175
|
+
orders.setKey('cursor', delta.cursor);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const byId = { ...orders.getKey('byId') };
|
|
179
|
+
const ids = new Set(orders.getKey('ids')); // fresh copies — reference equality drives notify
|
|
180
|
+
for (const o of delta.changed) { byId[o.id] = o; ids.add(o.id); }
|
|
181
|
+
for (const id of delta.removed) { delete byId[id]; ids.delete(id); }
|
|
182
|
+
orders.set({ byId, ids, cursor: delta.cursor });
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Drive it with a loop that never overlaps its own requests and pauses when the tab is hidden. A recursive timeout waits for each poll to finish before scheduling the next; an error backs off instead of hammering.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
export function startPolling(intervalMs = 4000) {
|
|
190
|
+
let stopped = false;
|
|
191
|
+
|
|
192
|
+
async function tick() {
|
|
193
|
+
if (stopped) return;
|
|
194
|
+
if (document.hidden) return schedule(intervalMs);
|
|
195
|
+
|
|
196
|
+
const cursor = orders.getKey('cursor');
|
|
197
|
+
if (cursor === null) {
|
|
198
|
+
await bootstrapOrders();
|
|
199
|
+
return schedule(intervalMs);
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const res = await fetch(`/api/orders?since=${encodeURIComponent(cursor)}`);
|
|
203
|
+
applyOrdersDelta(await res.json());
|
|
204
|
+
schedule(intervalMs);
|
|
205
|
+
} catch {
|
|
206
|
+
schedule(intervalMs * 2); // back off on failure
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function schedule(ms: number) {
|
|
211
|
+
if (!stopped) setTimeout(tick, ms);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
tick();
|
|
215
|
+
return () => { stopped = true; };
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Start it once and stop it on teardown. The cursor is opaque to the client; the server decodes it, and falls back to "from the start" when it can't. Upserts are by id, so a row sent twice across two polls is harmless.
|
|
220
|
+
|
|
221
|
+
```tsx
|
|
222
|
+
useEffect(() => startPolling(), []);
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## A live table: a lookup + polling
|
|
226
|
+
|
|
227
|
+
The single-map version above re-renders every reader of `byId` on any delta. When a poll touches three rows out of thousands, exactly three components should re-render. Give each row its own store with a [lookup](./composition.md#lookups-a-store-per-id): an index store holds the id list and the cursor, and each row reads its own store.
|
|
228
|
+
|
|
229
|
+
```tsx
|
|
230
|
+
import { createStore } from '@wildgrowth/voltix';
|
|
231
|
+
|
|
232
|
+
type Row = { id: string; total: number; status: string };
|
|
233
|
+
type Snapshot = { rows: Row[]; cursor: string };
|
|
234
|
+
type Delta = { changed: Row[]; removed: string[]; cursor: string };
|
|
235
|
+
|
|
236
|
+
const rows = createLookup((id: string) => ({ total: 0, status: '' }));
|
|
237
|
+
|
|
238
|
+
export const table = createStore({ ids: new Set<string>(), cursor: null as string | null, ready: false });
|
|
239
|
+
|
|
240
|
+
export async function bootstrapTable() {
|
|
241
|
+
const snap: Snapshot = await (await fetch('/api/rows')).json();
|
|
242
|
+
for (const r of snap.rows) rows.at(r.id).set({ total: r.total, status: r.status });
|
|
243
|
+
table.set({ ids: new Set(snap.rows.map((r) => r.id)), cursor: snap.cursor, ready: true });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function applyTableDelta(delta: Delta) {
|
|
247
|
+
// Only rows that changed touch their own store; siblings never re-render.
|
|
248
|
+
for (const r of delta.changed) rows.at(r.id).set({ total: r.total, status: r.status });
|
|
249
|
+
for (const id of delta.removed) rows.remove(id);
|
|
250
|
+
|
|
251
|
+
if (delta.changed.length || delta.removed.length) {
|
|
252
|
+
const ids = new Set(table.getKey('ids'));
|
|
253
|
+
for (const r of delta.changed) ids.add(r.id);
|
|
254
|
+
for (const id of delta.removed) ids.delete(id);
|
|
255
|
+
table.setKey('ids', ids); // fresh Set — the list re-renders only on add/remove
|
|
256
|
+
}
|
|
257
|
+
table.setKey('cursor', delta.cursor);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export { rows };
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
A delta that changes one row's `status` wakes that one `<TableRow>` and nothing else. `<Table>` re-renders only when a row is added or removed, because that is the only time `ids` changes.
|
|
264
|
+
|
|
265
|
+
```tsx
|
|
266
|
+
import { useStoreSelector } from '@wildgrowth/voltix';
|
|
267
|
+
import { table, rows } from './table';
|
|
268
|
+
|
|
269
|
+
function Table() {
|
|
270
|
+
const { ids, ready } = useStoreSelector(table, ['ids', 'ready']);
|
|
271
|
+
if (!ready) return <Spinner />;
|
|
272
|
+
return (
|
|
273
|
+
<table>
|
|
274
|
+
<tbody>{[...ids].map((id) => <TableRow key={id} id={id} />)}</tbody>
|
|
275
|
+
</table>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function TableRow({ id }: { id: string }) {
|
|
280
|
+
const { total, status } = useStoreSelector(rows.at(id), ['total', 'status']);
|
|
281
|
+
return (
|
|
282
|
+
<tr>
|
|
283
|
+
<td>{id}</td>
|
|
284
|
+
<td>{total}</td>
|
|
285
|
+
<td>{status}</td>
|
|
286
|
+
</tr>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Drive it with the same polling loop, reading `table.getKey('cursor')` and calling `bootstrapTable` and `applyTableDelta`. Use this shape for dashboards, order books, and live grids.
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# Walkthrough
|
|
2
|
+
|
|
3
|
+
A task board, built step by step: one store first, then actions, contracts, a store per task, and server loading. Each step adds one idea and the code keeps working between steps.
|
|
4
|
+
|
|
5
|
+
- [1. A store and a component](#1-a-store-and-a-component)
|
|
6
|
+
- [2. Several keys, independent re-renders](#2-several-keys-independent-re-renders)
|
|
7
|
+
- [3. Put the writes in actions](#3-put-the-writes-in-actions)
|
|
8
|
+
- [4. A task that owns itself](#4-a-task-that-owns-itself)
|
|
9
|
+
- [5. A store per id](#5-a-store-per-id)
|
|
10
|
+
- [6. Compose the app](#6-compose-the-app)
|
|
11
|
+
- [7. Derived keys and counters](#7-derived-keys-and-counters)
|
|
12
|
+
- [8. Render it](#8-render-it)
|
|
13
|
+
- [9. Load from a server](#9-load-from-a-server)
|
|
14
|
+
- [10. Persist what should outlive a reload](#10-persist-what-should-outlive-a-reload)
|
|
15
|
+
- [The finished shape](#the-finished-shape)
|
|
16
|
+
|
|
17
|
+
## 1. A store and a component
|
|
18
|
+
|
|
19
|
+
A store is a flat object of keys, created once at module scope. No provider, no context.
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
// ui.ts
|
|
23
|
+
import { createStore } from '@wildgrowth/voltix';
|
|
24
|
+
|
|
25
|
+
export type Filter = 'all' | 'open' | 'done';
|
|
26
|
+
|
|
27
|
+
export const ui = createStore({
|
|
28
|
+
filter: 'all' as Filter,
|
|
29
|
+
search: '',
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Read one key with `useStoreKey`. It returns the value itself.
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import { useStoreKey } from '@wildgrowth/voltix';
|
|
37
|
+
import { ui } from './ui';
|
|
38
|
+
|
|
39
|
+
function SearchBox() {
|
|
40
|
+
const search = useStoreKey(ui, 'search');
|
|
41
|
+
return <input value={search} onChange={(event) => ui.setKey('search', event.target.value)} />;
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 2. Several keys, independent re-renders
|
|
46
|
+
|
|
47
|
+
`useStoreSelector` takes an array of keys and returns just those keys.
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
function Toolbar() {
|
|
51
|
+
const { filter, search } = useStoreSelector(ui, ['filter', 'search']);
|
|
52
|
+
return <span>{filter} · {search}</span>;
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Subscriptions are per key: writing `filter` never wakes a component that only reads `search`.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
ui.subscribe(['filter'], () => console.log('filter'));
|
|
60
|
+
ui.subscribe(['search'], () => console.log('search'));
|
|
61
|
+
|
|
62
|
+
ui.setKey('filter', 'open'); // logs 'filter'
|
|
63
|
+
ui.setKey('search', 'milk'); // logs 'search'
|
|
64
|
+
ui.setKey('filter', 'open'); // logs nothing — same value, no write
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 3. Put the writes in actions
|
|
68
|
+
|
|
69
|
+
`createActions` collects a store's writes in one place and returns them as a plain object. The store itself is untouched.
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
// ui.ts
|
|
73
|
+
import { createStore, createActions } from '@wildgrowth/voltix';
|
|
74
|
+
|
|
75
|
+
export const ui = createStore({ filter: 'all' as Filter, search: '' });
|
|
76
|
+
|
|
77
|
+
export const uiActions = createActions(ui, (store) => ({
|
|
78
|
+
setFilter: (filter: Filter) => store.setKey('filter', filter),
|
|
79
|
+
setSearch: (query: string) => store.setKey('search', query),
|
|
80
|
+
clear: () => store.reset(),
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
uiActions.setFilter('open');
|
|
84
|
+
ui.setKey('search', 'milk'); // the store is still just a store
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Actions are plain functions in a plain object: import them where needed, and combine sets with object spread.
|
|
88
|
+
|
|
89
|
+
## 4. A task that owns itself
|
|
90
|
+
|
|
91
|
+
A task has rules (the title), behaviour, and a place to record a rejected write. Build it as a complete store before it joins any collection.
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
// task.ts
|
|
95
|
+
import { z } from 'zod';
|
|
96
|
+
import { createStore } from '@wildgrowth/voltix';
|
|
97
|
+
|
|
98
|
+
export function makeTask(id: string) {
|
|
99
|
+
const task = createStore(
|
|
100
|
+
{ title: '', done: false, error: '' },
|
|
101
|
+
{ schema: { title: z.string().trim().min(1).max(80) } } // its contract
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
task.onSchemaError((error) => task.setKey('error', error.issues[0].message)); // its failure policy
|
|
105
|
+
|
|
106
|
+
return task;
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Every task now behaves like this, wherever it ends up:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const task = makeTask('t1');
|
|
114
|
+
|
|
115
|
+
task.set({ title: ' ', error: '' }); // rejected by the contract
|
|
116
|
+
task.getKey('title'); // '' — the write never landed
|
|
117
|
+
task.getKey('error'); // 'Too small: expected string to have >=1 characters'
|
|
118
|
+
|
|
119
|
+
task.set({ title: ' Buy milk ', error: '' });
|
|
120
|
+
task.getKey('title'); // 'Buy milk' — the schema trimmed it on the way in
|
|
121
|
+
task.getKey('error'); // '' — cleared by the same write
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The contract runs on every write, and the error handler runs after the write settles. That is why `set({ title, error: '' })` can clear an old error without erasing the message a rejection just wrote.
|
|
125
|
+
|
|
126
|
+
## 5. A store per id
|
|
127
|
+
|
|
128
|
+
`createLookup` makes a **lookup**: a store per id. `at(id)` creates the store on first use and returns the same one after. The factory returns a finished store, so every task comes with its contract and error handling attached. Task actions go over the lookup, id first:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
// tasks.ts
|
|
132
|
+
import { createLookup, createActions } from '@wildgrowth/voltix';
|
|
133
|
+
import { makeTask } from './task';
|
|
134
|
+
|
|
135
|
+
export const tasks = createLookup(makeTask);
|
|
136
|
+
|
|
137
|
+
export const taskActions = createActions(tasks, (lookup) => ({
|
|
138
|
+
rename: (id: string, title: string) => lookup.at(id).set({ title, error: '' }),
|
|
139
|
+
toggle: (id: string) => lookup.at(id).toggle('done'),
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
taskActions.rename('t1', 'Buy milk');
|
|
143
|
+
taskActions.toggle('t1');
|
|
144
|
+
tasks.at('t1') === tasks.at('t1'); // true — memoized, the exact node makeTask built
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`at(id)` is how you reach the store for an id. `has`, `keys`, `size`, `remove`, and `clear` handle the lifecycle.
|
|
148
|
+
|
|
149
|
+
## 6. Compose the app
|
|
150
|
+
|
|
151
|
+
Bind the finished stores under one root. A key holding a store is a child; the other keys stay plain data.
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
// app.ts
|
|
155
|
+
import { createStore, createActions } from '@wildgrowth/voltix';
|
|
156
|
+
import { ui } from './ui';
|
|
157
|
+
import { tasks, taskActions } from './tasks';
|
|
158
|
+
|
|
159
|
+
export const app = createStore({
|
|
160
|
+
ids: new Set<string>(), // membership is the semantic — a Set can't hold a duplicate row
|
|
161
|
+
count: 0,
|
|
162
|
+
open: 0,
|
|
163
|
+
loading: false,
|
|
164
|
+
ui, // child store
|
|
165
|
+
tasks, // child lookup
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
export const appActions = createActions(app, (store) => ({
|
|
169
|
+
add(id: string, title: string) {
|
|
170
|
+
if (store.getKey('ids').has(id)) return; // idempotent — a double add is a no-op
|
|
171
|
+
taskActions.rename(id, title);
|
|
172
|
+
store.update('ids', (ids) => new Set(ids).add(id)); // fresh Set, so subscribers notify
|
|
173
|
+
store.increment('open');
|
|
174
|
+
},
|
|
175
|
+
remove(id: string) {
|
|
176
|
+
if (!store.getKey('ids').has(id)) return;
|
|
177
|
+
if (!store.select('tasks').at(id).getKey('done')) store.increment('open', -1);
|
|
178
|
+
store.update('ids', (ids) => {
|
|
179
|
+
const next = new Set(ids);
|
|
180
|
+
next.delete(id);
|
|
181
|
+
return next;
|
|
182
|
+
});
|
|
183
|
+
store.select('tasks').remove(id);
|
|
184
|
+
},
|
|
185
|
+
toggle(id: string) {
|
|
186
|
+
taskActions.toggle(id);
|
|
187
|
+
store.increment('open', store.select('tasks').at(id).getKey('done') ? -1 : 1);
|
|
188
|
+
},
|
|
189
|
+
}));
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`ids` is a `Set`: duplicates are impossible and `has` is O(1). The guards make `add` and `remove` safe to call twice, so a double-click can't duplicate a row or corrupt the counters. Writes always build a **fresh** `Set`, because Voltix compares by reference and mutating the current one changes data without notifying anyone.
|
|
193
|
+
|
|
194
|
+
Children stay out of the parent's state, and binding changes nothing about them:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
app.get(); // { ids: Set(0), count: 0, open: 0, loading: false }
|
|
198
|
+
app.children(); // ['ui', 'tasks']
|
|
199
|
+
app.select('ui') === ui; // true
|
|
200
|
+
app.select('tasks').at('t1') === tasks.at('t1'); // true
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Both ways of reaching a child are the same call; use whichever reads better. `ui.setKey('filter', 'open')` does the same as `app.select('ui').setKey('filter', 'open')`. `at` is for dynamic ids, like `app.select('tasks').at(someId)`. Note that `appActions` calls `taskActions` directly: actions are plain functions, so app behaviour is built out of task behaviour with ordinary calls.
|
|
204
|
+
|
|
205
|
+
## 7. Derived keys and counters
|
|
206
|
+
|
|
207
|
+
`derive` computes a key from other keys in the same store, inside the write that changes an input:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
app.derive('count', ['ids'], ({ ids }) => ids.size);
|
|
211
|
+
|
|
212
|
+
appActions.add('t1', 'Buy milk');
|
|
213
|
+
app.getKey('count'); // 1, already committed
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`open` depends on state spread across the lookup's stores, which `derive` cannot see. Update it from the actions that change it (`add`, `remove`, `toggle` above): O(1) per change instead of re-scanning every task.
|
|
217
|
+
|
|
218
|
+
## 8. Render it
|
|
219
|
+
|
|
220
|
+
Each component subscribes to exactly what it reads.
|
|
221
|
+
|
|
222
|
+
```tsx
|
|
223
|
+
import { useStoreKey, useStoreSelector } from '@wildgrowth/voltix';
|
|
224
|
+
import { app, appActions } from './app';
|
|
225
|
+
import { tasks } from './tasks';
|
|
226
|
+
|
|
227
|
+
function Header() {
|
|
228
|
+
const { count, open } = useStoreSelector(app, ['count', 'open']);
|
|
229
|
+
return <h1>{open} open / {count} total</h1>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function Row({ id }: { id: string }) {
|
|
233
|
+
const { title, done, error } = useStoreSelector(tasks.at(id), ['title', 'done', 'error']);
|
|
234
|
+
return (
|
|
235
|
+
<li>
|
|
236
|
+
<input type="checkbox" checked={done} onChange={() => appActions.toggle(id)} />
|
|
237
|
+
{title}
|
|
238
|
+
{error && <em>{error}</em>}
|
|
239
|
+
<button onClick={() => appActions.remove(id)}>×</button>
|
|
240
|
+
</li>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function Board() {
|
|
245
|
+
const ids = useStoreKey(app, 'ids');
|
|
246
|
+
return (
|
|
247
|
+
<>
|
|
248
|
+
<Header />
|
|
249
|
+
<ul>{[...ids].map((id) => <Row key={id} id={id} />)}</ul>
|
|
250
|
+
</>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Toggling one task re-renders **that `Row` only**. `Header` re-renders when `count` or `open` actually changed. Adding a task changes `ids` and `count`, so the list and `Header` update.
|
|
256
|
+
|
|
257
|
+
## 9. Load from a server
|
|
258
|
+
|
|
259
|
+
Async work goes in an action:
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
export async function loadBoard() {
|
|
263
|
+
app.setKey('loading', true);
|
|
264
|
+
try {
|
|
265
|
+
const res = await fetch('/api/tasks');
|
|
266
|
+
const rows: { id: string; title: string; done: boolean }[] = await res.json();
|
|
267
|
+
|
|
268
|
+
tasks.clear();
|
|
269
|
+
for (const r of rows) tasks.at(r.id).set({ title: r.title, done: r.done });
|
|
270
|
+
|
|
271
|
+
app.set({
|
|
272
|
+
ids: new Set(rows.map((r) => r.id)),
|
|
273
|
+
open: rows.filter((r) => !r.done).length,
|
|
274
|
+
loading: false,
|
|
275
|
+
});
|
|
276
|
+
} catch {
|
|
277
|
+
app.setKey('loading', false);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Server data is written straight into the task stores with `set`. It still passes each task's contract, so a malformed row is rejected per key instead of landing on the board.
|
|
283
|
+
|
|
284
|
+
Call it once where the data is first needed:
|
|
285
|
+
|
|
286
|
+
```tsx
|
|
287
|
+
useEffect(() => { loadBoard(); }, []);
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## 10. Persist what should outlive a reload
|
|
291
|
+
|
|
292
|
+
Persist preferences; server data reloads from the server. Seed the initial state from storage, then attach `persist`.
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
import { createStore, persist, loadPersistedState } from '@wildgrowth/voltix';
|
|
296
|
+
|
|
297
|
+
type UiState = { filter: Filter; search: string };
|
|
298
|
+
|
|
299
|
+
const persisted = loadPersistedState<UiState>(localStorage, 'board', ['filter']);
|
|
300
|
+
|
|
301
|
+
export const ui = createStore<UiState>(
|
|
302
|
+
{ filter: 'all', search: '', ...persisted },
|
|
303
|
+
{ schema: { filter: z.enum(['all', 'open', 'done']).catch('all') } }
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
persist<UiState>(ui, localStorage, 'board', ['filter']);
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
`.catch('all')` handles junk in storage (users edit it, old deploys leave old shapes behind): a bad value becomes `'all'` at construction instead of an impossible filter in your UI.
|
|
310
|
+
|
|
311
|
+
## The finished shape
|
|
312
|
+
|
|
313
|
+
```
|
|
314
|
+
app leaves: ids, count, open, loading
|
|
315
|
+
├── ui filter, search (+ actions, persisted, contract)
|
|
316
|
+
└── tasks lookup
|
|
317
|
+
└── <id> title, done, error (+ actions, contract, error handler)
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Reading back what each piece did:
|
|
321
|
+
|
|
322
|
+
| Step | Idea |
|
|
323
|
+
|---|---|
|
|
324
|
+
| 1–2 | One store, per-key subscriptions |
|
|
325
|
+
| 3 | `createActions` co-locates writes, the store stays clean |
|
|
326
|
+
| 4 | A child owns its contract, actions, and failure policy |
|
|
327
|
+
| 5 | A lookup is that store, once per id |
|
|
328
|
+
| 6 | Binding children into a root changes nothing about them |
|
|
329
|
+
| 7 | `derive` for same-store values, write-through for cross-store counts |
|
|
330
|
+
| 8 | Components subscribe to keys, so one row's write re-renders one row |
|
|
331
|
+
| 9 | Async in actions |
|
|
332
|
+
| 10 | Persist preferences, and let a contract repair corrupt storage |
|
|
333
|
+
|
|
334
|
+
Next: [Patterns](./patterns.md) for polling and live tables, [Composition](./composition.md) for deeper trees, [API reference](./api-reference.md) for the full surface.
|