@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
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
# API reference
|
|
2
|
+
|
|
3
|
+
Every export from `@wildgrowth/voltix`, plus every method on a store. Types are shown as they appear in the source. `T` is your state shape.
|
|
4
|
+
|
|
5
|
+
- [Getting started](./getting-started.md)
|
|
6
|
+
- [Core concepts](./core-concepts.md)
|
|
7
|
+
- [Composition](./composition.md)
|
|
8
|
+
- [Guides](./guides.md)
|
|
9
|
+
- [Patterns](./patterns.md)
|
|
10
|
+
|
|
11
|
+
Contents:
|
|
12
|
+
|
|
13
|
+
- [`createStore`](#createstore) and [`StoreOptions`](#storeoptions)
|
|
14
|
+
- [`createLookup`](#createlookup) and [`Lookup`](#lookup) — `at` · `has` · `remove` · `keys` · `size` · `clear`
|
|
15
|
+
- [`Store`](#store) — [`select`](#storeselect) · [`children`](#storechildren)
|
|
16
|
+
- [`createActions`](#createactions)
|
|
17
|
+
- [Schema contracts](#schema-contracts) — [initial state](#initial-state) · [`onSchemaError`](#onschemaerror)
|
|
18
|
+
- Store methods: [`get`](#get) · [`getKey`](#getkey) · [`set`](#set) · [`setKey`](#setkey) · [`update`](#update) · [`increment`](#increment) · [`toggle`](#toggle) · [`mergeSet`](#mergeset) · [`reset`](#reset) · [`batch`](#batch) · [`subscribe`](#subscribe) · [`pick`](#pick) · [`intercept`](#intercept) · [`onChange`](#onchange) · [`derive`](#derive) · [`onSchemaError`](#onschemaerror)
|
|
19
|
+
- [`useStoreKey`](#usestorekey)
|
|
20
|
+
- [`useStoreSelector`](#usestoreselector)
|
|
21
|
+
- [`createStoreHook`](#createstorehook)
|
|
22
|
+
- [Helpers](#helpers): [`merge`](#merge-helper) · [`persist`](#persist) · [`loadPersistedState`](#loadpersistedstate)
|
|
23
|
+
- [Storage interfaces](#storage-interfaces)
|
|
24
|
+
- [ESLint plugin](#eslint-plugin-wildgrowthvoltixeslint)
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## `createStore`
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
function createStore<T extends object>(
|
|
32
|
+
shape: T,
|
|
33
|
+
options?: StoreOptions<LeafState<T>>
|
|
34
|
+
): Store<T>;
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Creates a [`Store`](#store) from a state object. A key holding a store or [`Lookup`](#lookup) becomes a child (see [Composition](./composition.md)); every other key is a leaf. Passing a store *as* the shape, or passing a function, is a `TypeError`: stores go under a key, factories go to [`createLookup`](#createlookup).
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
import { createStore } from '@wildgrowth/voltix';
|
|
41
|
+
|
|
42
|
+
const store = createStore({ count: 0, name: 'Ada' });
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Call it once at module scope and share the reference.
|
|
46
|
+
|
|
47
|
+
### `StoreOptions`
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
interface StoreOptions<T extends object> {
|
|
51
|
+
equals?: { [K in keyof T]?: (prev: T[K], next: T[K]) => boolean };
|
|
52
|
+
schema?: { [K in keyof T]?: StandardSchemaV1<any, T[K]> };
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`equals` registers a per-key equality function, fixed at creation. On a write, the function runs with `(prev, next)`; returning `true` treats the values as equal and skips the write. For a lookup, options apply to every store it builds.
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
const store = createStore(
|
|
60
|
+
{ user: { id: 1, name: 'Ada' } },
|
|
61
|
+
{ equals: { user: (prev, next) => prev.id === next.id } }
|
|
62
|
+
);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`schema` registers a per-key write contract — see [Schema contracts](#schema-contracts).
|
|
66
|
+
|
|
67
|
+
`LeafState<T>` is `T` with its child-store keys removed, so options and every store method see only the reactive leaves.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## `createLookup`
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
function createLookup<Id extends PropertyKey, N extends NodeBrand>(
|
|
75
|
+
factory: (id: Id) => N
|
|
76
|
+
): Lookup<Id, N>;
|
|
77
|
+
function createLookup<Id extends PropertyKey, C extends object>(
|
|
78
|
+
factory: (id: Id) => C,
|
|
79
|
+
options?: StoreOptions<LeafState<C>>
|
|
80
|
+
): Lookup<Id, Store<C>>;
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Creates a [`Lookup`](#lookup): a store per id. The first `at(id)` runs `factory` and keeps the result. A factory that returns a built store is kept exactly as returned; a factory that returns a plain shape gets a store built for it, with `options` applied to each one.
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
import { createLookup } from '@wildgrowth/voltix';
|
|
87
|
+
|
|
88
|
+
const documents = createLookup((id: string) => ({ title: 'Untitled', body: '' }));
|
|
89
|
+
documents.at('readme').setKey('title', 'README');
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## `Lookup`
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
interface Lookup<Id extends PropertyKey, S extends object> {
|
|
96
|
+
at(id: Id): S;
|
|
97
|
+
has(id: Id): boolean;
|
|
98
|
+
remove(id: Id): boolean;
|
|
99
|
+
keys(): Id[];
|
|
100
|
+
size(): number;
|
|
101
|
+
clear(): void;
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`S` is the store type: exactly what the factory returns when it builds stores itself, or `Store<C>` when it returns a shape. Each store in the lookup has its own per-key subscriptions, so updating one item re-renders only that item. See [lookups](./composition.md#lookups-a-store-per-id).
|
|
106
|
+
|
|
107
|
+
`at(id)` returns the store at `id`, created on first access and reused after. It takes one id; to walk into that store's own children, continue with [`select`](#storeselect): `documents.at('readme').select('comments').at('c1')`.
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
documents.at('readme').setKey('title', 'README');
|
|
111
|
+
|
|
112
|
+
function DocumentRow({ id }: { id: string }) {
|
|
113
|
+
const title = useStoreKey(documents.at(id), 'title'); // only this row re-renders
|
|
114
|
+
return <li>{title}</li>;
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`has(id)` reports whether a store exists for `id`; `remove(id)` drops one (a later `at` starts fresh); `keys()` lists live ids in insertion order; `size()` counts them; `clear()` drops all.
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
documents.has('readme'); // true
|
|
122
|
+
documents.keys(); // ['readme']
|
|
123
|
+
documents.size(); // 1
|
|
124
|
+
documents.remove('readme'); // true
|
|
125
|
+
documents.clear();
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
When a store needs behaviour, the factory builds it complete:
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
const todos = createLookup((id: string) => {
|
|
132
|
+
const todo = createStore(
|
|
133
|
+
{ text: `todo ${id}`, done: false, error: '' },
|
|
134
|
+
{ schema: { text: z.string().min(1) } }
|
|
135
|
+
);
|
|
136
|
+
todo.onSchemaError((error) => todo.setKey('error', error.issues[0].message));
|
|
137
|
+
return todo;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
todos.at('t1'); // the store is exactly what the factory built — contract and handler included
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
For plain per-id data, a shape factory plus a second argument of [`StoreOptions`](#storeoptions) applies [`equals`](#storeoptions) and [`schema`](#schema-contracts) to every store the lookup builds:
|
|
144
|
+
|
|
145
|
+
```tsx
|
|
146
|
+
const presence = createLookup(
|
|
147
|
+
(userId: string) => ({ online: false, lastSeen: 0 }),
|
|
148
|
+
{ schema: { lastSeen: z.number().int().min(0) } }
|
|
149
|
+
);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Options apply to shape factories only; a factory that returns a built store configures it itself, and lookup options are ignored for it (with a dev warning).
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## `Store`
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
interface Store<T extends object> extends StoreType<LeafState<T>> {
|
|
160
|
+
select<K extends ChildKey<T>>(key: K): T[K];
|
|
161
|
+
children(): ChildKey<T>[];
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The value returned by `createStore(shape)`. It carries every [store method](#get) below (typed to the leaf keys), plus navigation for its child nodes.
|
|
166
|
+
|
|
167
|
+
### `Store.select`
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
select(key): childNode
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
One structural step: returns the child node (a nested `Store` or `Lookup`) bound at `key`. Takes exactly one key, always a child key, and chains for depth. Navigating to a key that is not a child throws a `RangeError` (and is a compile error first).
|
|
174
|
+
|
|
175
|
+
```tsx
|
|
176
|
+
const workspace = createStore({
|
|
177
|
+
name: 'Personal',
|
|
178
|
+
settings: createStore({ theme: 'dark', fontSize: 14 }),
|
|
179
|
+
documents: createLookup((id: string) => ({ title: 'Untitled', body: '' })),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
workspace.select('settings').setKey('fontSize', 16);
|
|
183
|
+
workspace.select('documents').at('readme').setKey('title', 'README');
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### `Store.children`
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
children(): ChildKey<T>[]
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The names of this store's child keys (the keys holding a nested store or lookup).
|
|
193
|
+
|
|
194
|
+
```tsx
|
|
195
|
+
workspace.children(); // ['settings', 'documents']
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## `createActions`
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
function createActions<S extends object, A extends object>(
|
|
204
|
+
store: S,
|
|
205
|
+
define: (store: S) => A
|
|
206
|
+
): A
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Defines actions for a store (or lookup) and returns them as a **standalone object**. The store is untouched. `define` receives the node fully typed, and actions can reach across a [composed](./composition.md) tree with `select(key)` / `at(id)`.
|
|
210
|
+
|
|
211
|
+
```tsx
|
|
212
|
+
const counter = createStore({ count: 0 });
|
|
213
|
+
|
|
214
|
+
const counterActions = createActions(counter, (store) => ({
|
|
215
|
+
increment: () => store.increment('count'),
|
|
216
|
+
add: (amount: number) => store.increment('count', amount),
|
|
217
|
+
reset: () => store.reset(['count']),
|
|
218
|
+
}));
|
|
219
|
+
|
|
220
|
+
counterActions.increment();
|
|
221
|
+
counter.setKey('count', 5); // the store is still just a store
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Actions over a lookup take the id first:
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
const todos = createLookup((id: string) => ({ text: '', done: false }));
|
|
228
|
+
|
|
229
|
+
const todoActions = createActions(todos, (lookup) => ({
|
|
230
|
+
add: (id: string, text: string) => lookup.at(id).set({ text }),
|
|
231
|
+
toggle: (id: string) => lookup.at(id).toggle('done'),
|
|
232
|
+
}));
|
|
233
|
+
|
|
234
|
+
todoActions.add('t1', 'Ship it');
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Actions are plain functions in a plain object: export them beside the store, combine sets with object spread (`{ ...baseActions, ...adminActions }`), and call one from another like any function.
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## Schema contracts
|
|
242
|
+
|
|
243
|
+
`options.schema` puts a per-key write contract on a store. A write runs the key's schema first: if it reports issues the write for that key is **vetoed** (the stored value is untouched and no subscriber is notified), and if the schema transforms, the parsed output is what gets stored.
|
|
244
|
+
|
|
245
|
+
Schemas are [Standard Schemas](https://standardschema.dev), the shared interface implemented by zod (≥3.24), valibot (≥1.0), and arktype (≥2.0). Voltix vendors only the *types*, so bring whichever library you like and Voltix stays dependency-free.
|
|
246
|
+
|
|
247
|
+
```tsx
|
|
248
|
+
import { z } from 'zod';
|
|
249
|
+
import { createStore } from '@wildgrowth/voltix';
|
|
250
|
+
|
|
251
|
+
const account = createStore(
|
|
252
|
+
{ balance: 0, email: '' },
|
|
253
|
+
{
|
|
254
|
+
schema: {
|
|
255
|
+
balance: z.number().min(0),
|
|
256
|
+
email: z.string().trim().toLowerCase(),
|
|
257
|
+
},
|
|
258
|
+
}
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
account.setKey('balance', -20); // vetoed; balance stays 0
|
|
262
|
+
account.setKey('email', ' A@B.CO '); // stored as 'a@b.co'
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
A schema's output type must match the key's type, so a mismatch is a compile error:
|
|
266
|
+
|
|
267
|
+
```tsx
|
|
268
|
+
createStore({ count: 0 }, { schema: { count: z.string() } }); // ✗ output is string, key is number
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Rules worth knowing:
|
|
272
|
+
|
|
273
|
+
- **Every write path is covered.** `set`, `setKey`, `update`, `increment`, `toggle`, `mergeSet`, and `reset` all validate. Inside a multi-key `set`, a vetoed key is dropped while the rest of the update commits.
|
|
274
|
+
- **Initial state is validated too.** See [below](#initial-state).
|
|
275
|
+
- **Order is schema → equality → notify.** Equality (`options.equals`) compares the *parsed* value, so a write that transforms into the current value is correctly skipped.
|
|
276
|
+
- **Validation must be synchronous.** An async schema is vetoed with an explanatory error; validate async input before writing it.
|
|
277
|
+
- **Rejections go to `console.error`** unless a handler is registered with [`onSchemaError`](#onschemaerror). In development the message names the key and vendor and lists each issue with its path; production builds log a terse line. A schema that throws vetoes the write.
|
|
278
|
+
- **Cost.** A store with no `schema` skips validation entirely. Once a store has one, a validated write costs roughly 30–75ns depending on the schema, and writes to its other keys carry a small fixed overhead (~11ns). Put contracts on boundary keys (server payloads, forms, storage) rather than on keys written every animation frame.
|
|
279
|
+
|
|
280
|
+
For a lookup, schemas passed in the second argument apply to every store it builds.
|
|
281
|
+
|
|
282
|
+
```tsx
|
|
283
|
+
const presence = createStore(
|
|
284
|
+
(userId: string) => ({ online: false, lastSeen: 0 }),
|
|
285
|
+
{ schema: { lastSeen: z.number().int().min(0) } }
|
|
286
|
+
);
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Schemas are locked at creation; there is no runtime add or remove.
|
|
290
|
+
|
|
291
|
+
### Initial state
|
|
292
|
+
|
|
293
|
+
Starting values are checked at construction, so a bad one is caught immediately. Transforms apply to them too, and `reset()` restores the transformed value.
|
|
294
|
+
|
|
295
|
+
```tsx
|
|
296
|
+
const store = createStore(
|
|
297
|
+
{ email: ' ADA@Example.COM ' },
|
|
298
|
+
{ schema: { email: z.string().trim().toLowerCase() } }
|
|
299
|
+
);
|
|
300
|
+
store.getKey('email'); // 'ada@example.com' — normalized before the first write
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
A bad starting value is logged to the console and kept as-is; construction never throws (a store seeded from corrupt `localStorage` should not crash the app at import). Handlers don't exist yet at construction, so these always go to the console. Stores built by a lookup are checked the same way as each is created.
|
|
304
|
+
|
|
305
|
+
### `onSchemaError`
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
onSchemaError(handler: (error: SchemaError<T>) => void): () => void
|
|
309
|
+
|
|
310
|
+
interface SchemaError<T> {
|
|
311
|
+
key: keyof T; // the key whose write was vetoed
|
|
312
|
+
issues: ReadonlyArray<StandardSchemaIssue>;
|
|
313
|
+
value: unknown; // the rejected value
|
|
314
|
+
store: StoreType<T>; // the node that rejected it
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Registers a handler for rejected writes and returns a disposer. It registers after construction, so it can use the finished store and any [`createActions`](#createactions) defined beside it.
|
|
319
|
+
|
|
320
|
+
```tsx
|
|
321
|
+
const account = createStore(
|
|
322
|
+
{ email: 'ada@lovelace.dev', emailError: '' },
|
|
323
|
+
{ schema: { email: z.string().email() } }
|
|
324
|
+
);
|
|
325
|
+
const accountActions = createActions(account, (store) => ({
|
|
326
|
+
flagEmail: (message: string) => store.setKey('emailError', message),
|
|
327
|
+
}));
|
|
328
|
+
|
|
329
|
+
account.onSchemaError((error) => accountActions.flagEmail(error.issues[0].message));
|
|
330
|
+
|
|
331
|
+
account.setKey('email', 'not-an-email');
|
|
332
|
+
account.getKey('email'); // 'ada@lovelace.dev' — still vetoed
|
|
333
|
+
account.getKey('emailError'); // 'Invalid email address'
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
While any handler is registered the console logging is off; disposing the last one turns it back on. The write stays vetoed either way. Handlers run synchronously, so keep them cheap; one that throws is logged and the rest still run. A handler that itself writes a rejected value does not re-enter; that second rejection goes to the console.
|
|
337
|
+
|
|
338
|
+
Registration is per store. In a lookup, register the handler inside the factory so every store gets one:
|
|
339
|
+
|
|
340
|
+
```tsx
|
|
341
|
+
const todos = createLookup((id: string) => {
|
|
342
|
+
const todo = createStore({ text: '', error: '' }, { schema: { text: z.string().min(1) } });
|
|
343
|
+
todo.onSchemaError((error) => todo.setKey('error', error.issues[0].message));
|
|
344
|
+
return todo;
|
|
345
|
+
});
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
## Store methods
|
|
351
|
+
|
|
352
|
+
A `Store` implements `StoreType<LeafState<T>>`. Every method operates on the reactive leaf keys.
|
|
353
|
+
|
|
354
|
+
### `get`
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
get(): T
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Returns the whole leaf state object. The reference is stable across writes (see [identity-stable state](./core-concepts.md#identity-stable-state)). Child stores are not included.
|
|
361
|
+
|
|
362
|
+
```tsx
|
|
363
|
+
const { count } = store.get();
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### `getKey`
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
getKey<K extends keyof T>(key: K): T[K]
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Returns one key's current value.
|
|
373
|
+
|
|
374
|
+
```tsx
|
|
375
|
+
const count = store.getKey('count');
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### `set`
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
set(update: Partial<T>): void
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Writes several keys in one update. Only the keys whose value actually changed notify.
|
|
385
|
+
|
|
386
|
+
```tsx
|
|
387
|
+
store.set({ count: 5, name: 'Bob' });
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
**Only the keys you pass are written.** A key absent from the partial is left alone, so spreading a patch cannot blank out state it doesn't mention. A key you do pass is written, including when its value is `undefined`.
|
|
391
|
+
|
|
392
|
+
```tsx
|
|
393
|
+
store.set({ name: 'Bob' }); // count untouched, name written
|
|
394
|
+
store.set({ count: undefined }); // count written as undefined, and notifies
|
|
395
|
+
store.set({ ...maybeEmpty }); // absent keys stay absent
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### `setKey`
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
setKey<K extends keyof T>(key: K, value: T[K]): void
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
Sets a single key. Equivalent to `set({ [key]: value })` for one key, on a faster path. `undefined` is a value like any other here: it commits and notifies.
|
|
405
|
+
|
|
406
|
+
```tsx
|
|
407
|
+
store.setKey('count', 5);
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### `update`
|
|
411
|
+
|
|
412
|
+
```ts
|
|
413
|
+
update<K extends keyof T>(key: K, updater: (prev: T[K]) => T[K]): void
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
Sets a key from its current value.
|
|
417
|
+
|
|
418
|
+
```tsx
|
|
419
|
+
store.update('count', (c) => c + 1);
|
|
420
|
+
store.update('tags', (tags) => [...tags, 'new']);
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
### `increment`
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
increment(key: NumericKeys<T>, amount?: number): void
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Adds `amount` (default `1`) to a numeric key. The type system allows this only on keys whose value is a number. Pass a negative amount to subtract.
|
|
430
|
+
|
|
431
|
+
```tsx
|
|
432
|
+
store.increment('count'); // + 1
|
|
433
|
+
store.increment('count', 10); // + 10
|
|
434
|
+
store.increment('count', -1); // - 1
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
### `toggle`
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
toggle(key: BooleanKeys<T>): void
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Flips a boolean key. Allowed only on keys whose value is a boolean.
|
|
444
|
+
|
|
445
|
+
```tsx
|
|
446
|
+
store.toggle('isOpen');
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### `mergeSet`
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
mergeSet<K extends keyof T>(key: K, value: T[K] extends object ? Partial<T[K]> : never): void
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Merges a partial into an object key and writes the result. The key gets a new object reference, so its subscribers are notified. For a pure computed merge with no write, use the [`merge` helper](#merge-helper).
|
|
456
|
+
|
|
457
|
+
```tsx
|
|
458
|
+
store.mergeSet('user', { name: 'Bob' }); // keeps other fields of user, updates name
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
### `reset`
|
|
462
|
+
|
|
463
|
+
```ts
|
|
464
|
+
reset(keys?: (keyof T)[]): void
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
Resets keys to the initial state passed at creation. With no argument, resets every key. With an array, resets only those keys. When a key has a transforming [schema](#schema-contracts), the initial state was committed post-transform, and `reset` restores that value.
|
|
468
|
+
|
|
469
|
+
Stores without child nodes or schemas retain the original input object for resets. Changing its top-level properties after `createStore` changes the values that `reset` restores, so treat that object as immutable. Stores with child nodes or schemas keep a shallow copy of their initial leaf values instead.
|
|
470
|
+
|
|
471
|
+
```tsx
|
|
472
|
+
store.reset(); // all keys back to initial
|
|
473
|
+
store.reset(['count']); // only count
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
`reset` covers this store's own leaves. Children are independent stores and keep their state — reset them directly, and clear lookups with `clear()`. A full logout looks like:
|
|
477
|
+
|
|
478
|
+
```tsx
|
|
479
|
+
app.reset();
|
|
480
|
+
app.select('profile').reset();
|
|
481
|
+
app.select('todos').clear();
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
### `batch`
|
|
485
|
+
|
|
486
|
+
```ts
|
|
487
|
+
batch(fn: () => void): void
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Runs `fn`, coalescing every write inside it into one notification round. Each affected subscription is notified once after `fn` returns. Separate subscriptions remain independent even if they use the same callback. Nested `batch` calls join the outer batch. See [batching](./core-concepts.md#batching).
|
|
491
|
+
|
|
492
|
+
```tsx
|
|
493
|
+
store.batch(() => {
|
|
494
|
+
store.setKey('count', 10);
|
|
495
|
+
store.increment('count', 5);
|
|
496
|
+
});
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
### `subscribe`
|
|
500
|
+
|
|
501
|
+
```ts
|
|
502
|
+
subscribe(keys: (keyof T)[], listener: () => void): () => void
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
Registers a listener for a set of keys and returns an unsubscribe function. The listener runs when any of those keys changes. It receives no arguments; read current values with `getKey` or `get`. For change detection with old and new values, see [`onChange`](#onchange).
|
|
506
|
+
|
|
507
|
+
Each `subscribe` call registers an independent subscription, even when the same callback or keys are reused. A single `subscribe(['a', 'b'], fn)` runs `fn` once when both keys change in one `set` or `batch`; two separate subscriptions using `fn` run it once per affected subscription. Each unsubscribe removes only its own subscription.
|
|
508
|
+
|
|
509
|
+
```tsx
|
|
510
|
+
const off = store.subscribe(['count'], () => {
|
|
511
|
+
console.log('count is now', store.getKey('count'));
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
store.setKey('count', 1); // logs: count is now 1
|
|
515
|
+
|
|
516
|
+
off(); // listener removed
|
|
517
|
+
store.setKey('count', 2); // logs nothing
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
### `pick`
|
|
521
|
+
|
|
522
|
+
```ts
|
|
523
|
+
pick<K extends keyof T>(keys: K[]): Pick<T, K>
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
Returns a snapshot object with the requested keys. Imperative, synchronous, and does not subscribe. (Navigation into child nodes is [`Store.select`](#storeselect), a different method.)
|
|
527
|
+
|
|
528
|
+
```tsx
|
|
529
|
+
const { count, name } = store.pick(['count', 'name']);
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
### `intercept`
|
|
533
|
+
|
|
534
|
+
```ts
|
|
535
|
+
intercept(fn: Interceptor<T>, keys?: (keyof T)[]): () => void
|
|
536
|
+
|
|
537
|
+
type Interceptor<T> = (update: Partial<T>, state: T) => Partial<T> | false | void
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
Intercepts writes before they commit. The function receives the pending update and the pre-update state, and answers with its return value: a partial **replaces** the update, `false` **vetoes** it, and returning nothing passes it through unchanged. With `keys`, it runs only when the update touches one of them. Interceptors run in registration order, a replacement flows into the next one, and a thrown error blocks the write and is logged. Returns a function that removes the interceptor.
|
|
541
|
+
|
|
542
|
+
```tsx
|
|
543
|
+
// Clamp count into range on every write that includes it
|
|
544
|
+
const remove = store.intercept((update) => {
|
|
545
|
+
if (typeof update.count === 'number') {
|
|
546
|
+
return { ...update, count: Math.max(0, Math.min(100, update.count)) };
|
|
547
|
+
}
|
|
548
|
+
}, ['count']);
|
|
549
|
+
|
|
550
|
+
// Veto deletes while a sync is running
|
|
551
|
+
store.intercept((update, state) => {
|
|
552
|
+
if (state.syncing && update.items) return false;
|
|
553
|
+
}, ['items']);
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
Writes made through `setKey`, `update`, `increment`, `toggle`, `mergeSet`, and `reset` all run through your interceptors once any is registered. See the [middleware guide](./guides.md#middleware).
|
|
557
|
+
|
|
558
|
+
### `onChange`
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
onChange<K extends keyof T>(
|
|
562
|
+
keys: K[],
|
|
563
|
+
callback: (values: Pick<T, K>, prev: Pick<T, K>) => void
|
|
564
|
+
): () => void
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
Reacts to key changes outside React, with both the new and previous values. Synchronous writes in the same tick are coalesced and the callback fires once on a microtask with the final values. Returns an unsubscribe function. See [reacting outside React](./guides.md#reacting-outside-react).
|
|
568
|
+
|
|
569
|
+
```tsx
|
|
570
|
+
const off = store.onChange(['count'], (values, prev) => {
|
|
571
|
+
console.log(prev.count, '->', values.count);
|
|
572
|
+
});
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
### `derive`
|
|
576
|
+
|
|
577
|
+
```ts
|
|
578
|
+
derive<K extends keyof T, I extends keyof T>(
|
|
579
|
+
outKey: K,
|
|
580
|
+
inputs: I[],
|
|
581
|
+
compute: (values: Pick<T, I>) => T[K]
|
|
582
|
+
): () => void
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
Derives `outKey` from `inputs`. `compute` runs once immediately to set the initial value, then runs synchronously inside any write that changes an input and commits its result in the same update. Subscribers to `outKey` wake only when the computed value actually changes. Returns a disposer that stops deriving. See [derived keys](./guides.md#derived-keys).
|
|
586
|
+
|
|
587
|
+
`compute` always receives the current values of all declared inputs, including the ones a write didn't touch. It runs on every write that touches an input, so keep it cheap.
|
|
588
|
+
|
|
589
|
+
```tsx
|
|
590
|
+
const store = createStore({ first: 'Ada', last: 'Lovelace', full: '' });
|
|
591
|
+
store.derive('full', ['first', 'last'], ({ first, last }) => `${first} ${last}`);
|
|
592
|
+
|
|
593
|
+
store.setKey('first', 'Grace'); // full becomes 'Grace Lovelace' in the same update
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
---
|
|
597
|
+
|
|
598
|
+
## `useStoreKey`
|
|
599
|
+
|
|
600
|
+
```ts
|
|
601
|
+
function useStoreKey<T extends object, K extends keyof T>(
|
|
602
|
+
store: StoreType<T>,
|
|
603
|
+
key: K
|
|
604
|
+
): T[K]
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
Subscribes to one key and returns its value. The component re-renders only when that key changes (`Object.is`, plus any [`options.equals`](#storeoptions)). Works on any store, including one from a lookup: `useStoreKey(lookup.at(id), 'title')`.
|
|
608
|
+
|
|
609
|
+
```tsx
|
|
610
|
+
const count = useStoreKey(store, 'count');
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
---
|
|
614
|
+
|
|
615
|
+
## `useStoreSelector`
|
|
616
|
+
|
|
617
|
+
```ts
|
|
618
|
+
function useStoreSelector<T extends object, S extends SelectorInput<T>>(
|
|
619
|
+
store: StoreType<T>,
|
|
620
|
+
selector: S
|
|
621
|
+
): Picked<T, S>
|
|
622
|
+
|
|
623
|
+
type SelectorInput<T> = ReadonlyArray<keyof T | { [K in keyof T]?: (prev: T[K], next: T[K]) => boolean }>
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
Subscribes to several keys and returns just those keys. The selector is always an array; each element is a key string or a `{ key: compareFn }` object. The component re-renders when a selected key changes. A compare function makes that call for its key: return `true` to treat two values as equal and skip the re-render. Store-level [`options.equals`](#storeoptions) applies when no per-key compare is given.
|
|
627
|
+
|
|
628
|
+
```tsx
|
|
629
|
+
// Keys
|
|
630
|
+
const { count, name } = useStoreSelector(store, ['count', 'name']);
|
|
631
|
+
|
|
632
|
+
// Custom compare for one key
|
|
633
|
+
const { tasks } = useStoreSelector(store, [
|
|
634
|
+
{ tasks: (prev, next) => prev.length === next.length },
|
|
635
|
+
]);
|
|
636
|
+
|
|
637
|
+
// Mixed
|
|
638
|
+
const { count, tasks } = useStoreSelector(store, [
|
|
639
|
+
'count',
|
|
640
|
+
{ tasks: (prev, next) => prev.length === next.length },
|
|
641
|
+
]);
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
Key-string selectors can be inline array literals; they are matched by value. A selector containing a compare function is matched by identity, so define it outside render (or memoize it) to avoid re-subscribing every render.
|
|
645
|
+
|
|
646
|
+
---
|
|
647
|
+
|
|
648
|
+
## `createStoreHook`
|
|
649
|
+
|
|
650
|
+
```ts
|
|
651
|
+
function createStoreHook<T extends object>(
|
|
652
|
+
store: StoreType<T>
|
|
653
|
+
): <S extends SelectorInput<T>>(selector: S) => Picked<T, S>
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
Returns a hook bound to a store, with the same selector API as `useStoreSelector`. Types are inferred from the store.
|
|
657
|
+
|
|
658
|
+
```tsx
|
|
659
|
+
const useMyStore = createStoreHook(myStore);
|
|
660
|
+
|
|
661
|
+
function View() {
|
|
662
|
+
const { count, name } = useMyStore(['count', 'name']);
|
|
663
|
+
return <span>{count} {name}</span>;
|
|
664
|
+
}
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
## Helpers
|
|
670
|
+
|
|
671
|
+
Standalone functions, imported from `@wildgrowth/voltix`.
|
|
672
|
+
|
|
673
|
+
### `merge` (helper)
|
|
674
|
+
|
|
675
|
+
```ts
|
|
676
|
+
function merge<T extends object>(base: T, patch: Partial<T>): T
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
Shallow-merge on plain data: `{ ...base, ...patch }`, typed so the patch can only carry keys of the base. Pair it with a write when the result should be stored, or use [`mergeSet`](#mergeset), which merges and writes in one call.
|
|
680
|
+
|
|
681
|
+
```tsx
|
|
682
|
+
const preview = merge(store.getKey('user'), { name: 'Bob' }); // computed, nothing written
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
### `persist`
|
|
686
|
+
|
|
687
|
+
```ts
|
|
688
|
+
function persist<T extends object>(
|
|
689
|
+
store: StoreType<T>,
|
|
690
|
+
storage: Storage | StorageSupportingInterface | AsyncStorageSupportingInterface,
|
|
691
|
+
persistKey: string,
|
|
692
|
+
keys: (keyof T)[],
|
|
693
|
+
parser?: PersistParser
|
|
694
|
+
): () => void
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
Persists the given keys: each one is written to storage as it changes, under `` `${persistKey}:${keyName}` ``. Works with synchronous storage (`localStorage`, `sessionStorage`) and asynchronous storage (React Native `AsyncStorage`); async writes are fire-and-forget, so a slow write never blocks the state update. Returns an unsubscribe that stops persisting. See [persistence](./guides.md#persistence).
|
|
698
|
+
|
|
699
|
+
The default format is JSON plus `Date`, `Map`, `Set`, and non-finite numbers, which all survive the round-trip. To store other types, pass a [`parser`](#storage-interfaces) — the same one to `loadPersistedState`.
|
|
700
|
+
|
|
701
|
+
```tsx
|
|
702
|
+
persist(store, localStorage, 'myapp', ['theme', 'isLoggedIn']);
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
### `loadPersistedState`
|
|
706
|
+
|
|
707
|
+
```ts
|
|
708
|
+
function loadPersistedState<T extends object>(
|
|
709
|
+
storage: Storage | StorageSupportingInterface,
|
|
710
|
+
persistKey: string,
|
|
711
|
+
keys: (keyof T)[],
|
|
712
|
+
parser?: PersistParser
|
|
713
|
+
): Partial<T>
|
|
714
|
+
function loadPersistedState<T extends object>(
|
|
715
|
+
storage: AsyncStorageSupportingInterface,
|
|
716
|
+
persistKey: string,
|
|
717
|
+
keys: (keyof T)[],
|
|
718
|
+
parser?: PersistParser
|
|
719
|
+
): Promise<Partial<T>>
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
Reads keys written by [`persist`](#persist) and returns them as a partial state. Returns synchronously for sync storage and a `Promise` for async storage. Use it to seed the initial state at creation, or to hydrate a store after creation. Pass the same `parser` given to `persist`, if any.
|
|
723
|
+
|
|
724
|
+
```tsx
|
|
725
|
+
// Sync
|
|
726
|
+
const persisted = loadPersistedState(localStorage, 'myapp', ['theme']);
|
|
727
|
+
const store = createStore({ theme: 'light', ...persisted });
|
|
728
|
+
|
|
729
|
+
// Async
|
|
730
|
+
const persisted = await loadPersistedState(AsyncStorage, 'myapp', ['theme']);
|
|
731
|
+
const store = createStore({ theme: 'light', ...persisted });
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
---
|
|
735
|
+
|
|
736
|
+
## Storage interfaces
|
|
737
|
+
|
|
738
|
+
```ts
|
|
739
|
+
interface StorageSupportingInterface {
|
|
740
|
+
getItem(key: string): string | null;
|
|
741
|
+
setItem(key: string, value: string): void;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
interface AsyncStorageSupportingInterface {
|
|
745
|
+
getItem(key: string): Promise<string | null>;
|
|
746
|
+
setItem(key: string, value: string): Promise<void>;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
interface PersistParser {
|
|
750
|
+
stringify(value: any): string;
|
|
751
|
+
parse(text: string): any;
|
|
752
|
+
}
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
The persistence functions accept the DOM `Storage` type, any object matching `StorageSupportingInterface` (sync), or any object matching `AsyncStorageSupportingInterface` (async, such as React Native `AsyncStorage`).
|
|
756
|
+
|
|
757
|
+
A `PersistParser` controls how values become strings. The default handles JSON plus `Date`, `Map`, `Set`, and non-finite numbers. Anything with `stringify`/`parse` fits: `JSON` gives you raw behaviour, [superjson](https://github.com/flightcontrolhq/superjson) covers BigInt, RegExp, and registered classes. Use one parser consistently per prefix.
|
|
758
|
+
|
|
759
|
+
---
|
|
760
|
+
|
|
761
|
+
## ESLint plugin (`@wildgrowth/voltix/eslint`)
|
|
762
|
+
|
|
763
|
+
Voltix ships an ESLint plugin at the `@wildgrowth/voltix/eslint` subpath with one rule, `voltix/no-unused-selector-keys`. Every key in a selector is a subscription, so a selected-but-unused key means re-renders for data the component never reads. The rule warns on exactly that:
|
|
764
|
+
|
|
765
|
+
```tsx
|
|
766
|
+
const { count } = useStoreSelector(store, ['count', 'name']);
|
|
767
|
+
// ^ 'name' is selected but never destructured
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
It works with zero configuration. `useStoreSelector(store, [...])` calls are recognized by name; a hook made with `createStoreHook` is recognized by how it's used — any `use*`-named call taking a selector array whose result is destructured, in any file. Both forms include `{ key: compareFn }` custom-compare entries. Requires ESLint 9+ (flat config).
|
|
771
|
+
|
|
772
|
+
```js
|
|
773
|
+
// eslint.config.js
|
|
774
|
+
import voltix from '@wildgrowth/voltix/eslint';
|
|
775
|
+
|
|
776
|
+
export default [
|
|
777
|
+
{
|
|
778
|
+
plugins: { voltix },
|
|
779
|
+
rules: { 'voltix/no-unused-selector-keys': 'warn' },
|
|
780
|
+
},
|
|
781
|
+
];
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
The recommended preset is also available for spreading:
|
|
785
|
+
|
|
786
|
+
```js
|
|
787
|
+
import voltix from '@wildgrowth/voltix/eslint';
|
|
788
|
+
|
|
789
|
+
export default [voltix.configs.recommended];
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
The rule warns only when it can prove a key is unused; anything unclear is skipped:
|
|
793
|
+
|
|
794
|
+
- The result must be destructured inline. A `...rest` element covers the remaining keys only when the rest variable is read somewhere — a rest nobody touches proves nothing, and the other keys are still checked.
|
|
795
|
+
- For a hook recognized by usage (anything other than a literal `useStoreSelector` call), at least one selected key must appear in the destructuring — that overlap is what marks it as a selector call, so an unrelated hook that happens to take a string array is left alone.
|
|
796
|
+
|
|
797
|
+
```tsx
|
|
798
|
+
const { items } = useCart(['items', 'total']); // provably unused 'total' — warns
|
|
799
|
+
const slice = useCart(['items', 'total']); // not destructured — skipped
|
|
800
|
+
const { items, ...rest } = useCart(['items', 'total']);
|
|
801
|
+
render(rest); // rest is read — skipped
|
|
802
|
+
const { items, ...rest } = useCart(['items', 'total']); // rest never read — 'total' warns
|
|
803
|
+
const { data } = useQueries(['users', 'posts']); // no overlap — not a selector call, skipped
|
|
804
|
+
```
|