@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.
@@ -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
+ ```