@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,64 @@
1
+ # Comparison
2
+
3
+ Where Voltix fits, and how it performs. If you already know you want per-key subscriptions in a single store, skip to [Getting started](./getting-started.md).
4
+
5
+ - [Getting started](./getting-started.md)
6
+ - [Core concepts](./core-concepts.md)
7
+ - [Composition](./composition.md)
8
+ - [Patterns](./patterns.md)
9
+
10
+ ## The model
11
+
12
+ Voltix is one store of keys with listeners per key. A component names the keys it reads, and a write re-renders the readers of the keys that changed.
13
+
14
+ ```tsx
15
+ // Voltix — subscribe to a key
16
+ const name = useStoreKey(user, 'name');
17
+
18
+ // Zustand — subscribe with a selector function
19
+ const name = useUserStore((state) => state.name);
20
+ ```
21
+
22
+ For a single field the two behave the same. The difference shows as reads widen: several keys in Voltix is still an array — `useStoreSelector(user, ['name', 'plan'])` — while a selector that returns an object needs an equality strategy (`useShallow`, memoized selectors) to avoid re-rendering on every write.
23
+
24
+ | Library | Subscription unit |
25
+ |---|---|
26
+ | **Voltix** | A key in a shared store |
27
+ | Zustand | A selector function over one store |
28
+ | Jotai | An atom in a dependency graph |
29
+ | Valtio | Properties touched on a proxy |
30
+ | nanostores | An individual atom or map |
31
+ | Preact Signals | A signal read inside an effect |
32
+ | Solid | A signal read inside a computation |
33
+ | Legend-State | A node of an observable tree |
34
+
35
+ ## Performance
36
+
37
+ Store-engine microbenchmarks (vanilla stores, no React), in operations per second, higher is faster. Each case runs in its own process and reports the median of 40 runs of about 10 ms each (Node 25, Apple M4 Pro). Reproduce with `npm run bench` ([`bench/vs.ts`](https://github.com/wildgrowth-oss/voltix/blob/main/bench/vs.ts)).
38
+
39
+ | operation | **Voltix** | Preact Signals | Zustand | nanostores | Solid | Valtio | Jotai | Legend-State |
40
+ |---|---|---|---|---|---|---|---|---|
41
+ | update a value + notify | **187.8M** | 47.4M | 39.1M | 31.0M | 13.1M | 6.7M | 2.7M | 2.7M |
42
+ | functional update (`x => x + 1`) | **192.0M** | 49.4M | 37.8M | 30.3M | 13.7M | 6.5M | 2.3M | 2.7M |
43
+ | fine-grained update (1 of 1000) | **184.2M** | 47.9M | 39.1M † | 30.4M | 12.5M | 161K ‡ | 2.6M | 1.1M |
44
+ | notify 1000 subscribers | **563K** | 35K | 232K | 111K | 24K | — | 64K | 35K |
45
+ | update 50 keys as one transaction | 617K | **820K** | 530K | 516K | 279K | — | 51K | 26K |
46
+ | subscribe + unsubscribe | **44.4M** | 29.1M | 31.4M | 41.7M | 8.9M | 15.5M | 3.1M | 26.5M |
47
+ | create 1,000 units | 42K | **199K** | 39K | 6K | 86K | 388 | 44K | 15K |
48
+
49
+ <sub>† Zustand's fine-grained row uses separate stores; its single-store selector pattern is O(N) in the number of subscribers. ‡ Valtio batches notifications asynchronously and its per-key subscribe is O(N). The transaction row uses each library's batch where it has one and Voltix's `set`; a signal is a smaller unit than a keyed store, which is what the creation row shows.</sub>
50
+
51
+ Every library here re-renders only the components whose data changed. The throughput gap shows up under a high rate of writes: drags, streaming data, animation, large grids of independently updating cells.
52
+
53
+ ## Where Voltix fits
54
+
55
+ Pick Voltix for views made of many independent values where each write should re-render only its readers: grids and tables with per-row state, editors with many fields, dashboards with live numbers, anything written at high frequency. Stores [compose](./composition.md) into a tree (nested stores with `select(key)`, a store per id with `at(id)`), so a large app stays one tree. The [Patterns](./patterns.md) page shows the request, polling, and live-table shapes end to end.
56
+
57
+ Cases where another shape fits better:
58
+
59
+ - Deep mutation of one big nested object: a proxy store (Valtio) tracks nested writes for you; Voltix updates by key.
60
+ - State as an async dependency graph, where values derive from other async values: an atom library (Jotai).
61
+ - One store shared across frameworks: nanostores.
62
+ - Caching server data (queries, retries, invalidation): a fetching layer such as React Query owns that; Voltix holds the app state beside it.
63
+
64
+ See [Getting started](./getting-started.md) to try it, or the [API reference](./api-reference.md) for the full surface.
@@ -0,0 +1,248 @@
1
+ # Composition
2
+
3
+ A key can hold another store. That is the whole mechanism: build small stores, bind them under keys, and you get a tree.
4
+
5
+ Build the child first, bind it after. Binding changes nothing about the child, and a nested store is exactly as fast as a flat one.
6
+
7
+ - [The example](#the-example)
8
+ - [Nesting a store](#nesting-a-store)
9
+ - [Leaves and children](#leaves-and-children)
10
+ - [Lookups: a store per id](#lookups-a-store-per-id)
11
+ - [Navigating: `select` and `at`](#navigating-select-and-at)
12
+ - [Nested lookups](#nested-lookups)
13
+ - [What re-renders](#what-re-renders)
14
+
15
+ See also [Core concepts](./core-concepts.md) for the vocabulary and the [API reference](./api-reference.md) for signatures.
16
+
17
+ ## The example
18
+
19
+ Everything below builds one thing: a **workspace** with user settings and a set of documents, where each document has its own comments.
20
+
21
+ ```
22
+ workspace leaves: name, syncing
23
+ ├── settings theme, fontSize (a nested store)
24
+ └── documents a lookup: one store per document id
25
+ └── <docId> title, body, dirty, error (contract + actions + error policy)
26
+ └── comments a lookup: one store per comment id
27
+ └── <id> author, text, resolved
28
+ ```
29
+
30
+ ## Nesting a store
31
+
32
+ Build the child first. Settings is a normal store with its own actions. It works on its own and knows nothing about any workspace:
33
+
34
+ ```tsx
35
+ import { createStore, createActions } from '@wildgrowth/voltix';
36
+
37
+ export const settings = createStore({ theme: 'dark' as 'dark' | 'light', fontSize: 14 });
38
+
39
+ export const settingsActions = createActions(settings, (store) => ({
40
+ toggleTheme: () => store.setKey('theme', store.getKey('theme') === 'dark' ? 'light' : 'dark'),
41
+ setFontSize: (px: number) => store.setKey('fontSize', px),
42
+ }));
43
+ ```
44
+
45
+ Then bind it under a key:
46
+
47
+ ```tsx
48
+ export const workspace = createStore({
49
+ name: 'Personal',
50
+ syncing: false,
51
+ settings, // ← a finished store becomes a child
52
+ });
53
+
54
+ workspace.setKey('name', 'Team'); // a parent leaf
55
+ settingsActions.toggleTheme(); // the child's actions still work
56
+ workspace.select('settings') === settings; // true — binding did not copy or rebuild it
57
+ ```
58
+
59
+ Passing a store *as* the shape is a `TypeError`. A store goes under a key: write `createStore({ settings })`, never `createStore(settings)`.
60
+
61
+ ## Leaves and children
62
+
63
+ What you put under a key decides what the key is:
64
+
65
+ - Data (numbers, strings, booleans, objects, arrays) makes a **leaf**: the store's own state. `get()` returns leaves, `setKey` writes them, components subscribe to them.
66
+ - A store or a lookup makes a **child**. The child keeps its own state; the parent just holds the reference. You read and write a child on the child itself.
67
+
68
+ The `workspace` from above has two leaves and one child:
69
+
70
+ ```tsx
71
+ workspace.get(); // { name: 'Team', syncing: false } — the workspace's own state
72
+ workspace.children(); // ['settings'] — the settings data lives in `settings`, so `get()` has none of it
73
+
74
+ workspace.setKey('syncing', true); // leaf write
75
+ workspace.select('settings').setKey('fontSize', 16); // child state is written on the child
76
+ ```
77
+
78
+ `workspace.setKey('settings', …)` is a compile error: a child key holds no workspace state to write.
79
+
80
+ A plain object never becomes a child. `cursor: { line: 1, column: 1 }` is one leaf value: replace the whole object to update it, and subscribers to `cursor` hear one change. When the fields should update independently, use a store instead: `cursor: createStore({ line: 1, column: 1 })`.
81
+
82
+ ## Lookups: a store per id
83
+
84
+ `createLookup(factory)` makes a **lookup**: a store per id. `at(id)` returns the store for that id — the first call runs your factory to create it, later calls return the same one. Use a lookup for per-item state: documents, rows, entities.
85
+
86
+ The factory builds each store completely, so every document comes with its contract and error handling attached. Actions go over the lookup, taking the id first:
87
+
88
+ ```tsx
89
+ import { z } from 'zod';
90
+ import { createStore, createLookup, createActions } from '@wildgrowth/voltix';
91
+
92
+ export function makeDocument(id: string) {
93
+ const document = createStore(
94
+ { title: 'Untitled', body: '', dirty: false, error: '' },
95
+ { schema: { title: z.string().trim().min(1).max(120) } }
96
+ );
97
+
98
+ document.onSchemaError((error) => document.setKey('error', error.issues[0].message));
99
+
100
+ return document;
101
+ }
102
+
103
+ export const documents = createLookup(makeDocument);
104
+
105
+ export const documentActions = createActions(documents, (lookup) => ({
106
+ rename: (id: string, title: string) => lookup.at(id).set({ title, error: '' }),
107
+ edit: (id: string, body: string) => lookup.at(id).set({ body, dirty: true }),
108
+ markSaved: (id: string) => lookup.at(id).setKey('dirty', false),
109
+ }));
110
+ ```
111
+
112
+ Each document checks its own writes and reports its own failures:
113
+
114
+ ```tsx
115
+ documentActions.rename('spec', ' '); // rejected by the contract
116
+ documents.at('spec').getKey('title'); // 'Untitled' — the write never landed
117
+ documents.at('spec').getKey('error'); // 'Too small: expected string to have >=1 characters'
118
+
119
+ documentActions.rename('spec', ' Design spec ');
120
+ documents.at('spec').getKey('title'); // 'Design spec' — the contract trimmed it on the way in
121
+ ```
122
+
123
+ When the per-item state is plain data with no behaviour, return a shape from the factory instead and the lookup builds the stores for you. A second argument applies [`StoreOptions`](./api-reference.md#storeoptions) to each one:
124
+
125
+ ```tsx
126
+ const presence = createLookup(
127
+ (userId: string) => ({ online: false, lastSeen: 0 }),
128
+ { schema: { lastSeen: z.number().int().min(0) } }
129
+ );
130
+ ```
131
+
132
+ Options apply to shape factories only. A factory that builds its own stores configures them itself, so lookup options are ignored there (with a dev warning).
133
+
134
+ A lookup has no keys of its own. Its surface is `at` plus lifecycle:
135
+
136
+ ```tsx
137
+ documents.has('readme'); // true
138
+ documents.keys(); // ['readme', 'spec'] — live ids, insertion order
139
+ documents.size(); // 2
140
+ documents.remove('spec'); // true; a later at('spec') builds a fresh store
141
+ documents.clear(); // drop every store
142
+ ```
143
+
144
+ `remove` and `clear` drop the store and its subscriptions. One thing they can't clean up: wiring to something **external** (a subscription on another store, a timer, a socket). Keep those disposers where you can reach them and call one before removing:
145
+
146
+ ```tsx
147
+ const stopSync = new Map<string, () => void>();
148
+
149
+ function makePresence(userId: string) {
150
+ const presence = createStore({ online: false });
151
+ stopSync.set(userId, socket.onChange(['users'], () => presence.setKey('online', socket.has(userId))));
152
+ return presence;
153
+ }
154
+
155
+ stopSync.get(userId)?.();
156
+ stopSync.delete(userId);
157
+ presence.remove(userId);
158
+ ```
159
+
160
+ In React, a component subscribes to one store from the lookup, so editing one document re-renders one row:
161
+
162
+ ```tsx
163
+ function DocumentRow({ id }: { id: string }) {
164
+ const { title, dirty } = useStoreSelector(documents.at(id), ['title', 'dirty']);
165
+ return <li>{title}{dirty && ' •'}</li>;
166
+ }
167
+ ```
168
+
169
+ A lookup binds under a key exactly like a store. With both pieces built, the workspace takes its full shape from [the example](#the-example):
170
+
171
+ ```tsx
172
+ export const workspace = createStore({
173
+ name: 'Personal',
174
+ syncing: false,
175
+ settings, // child — a store
176
+ documents, // child — a lookup
177
+ });
178
+ ```
179
+
180
+ ## Navigating: `select` and `at`
181
+
182
+ Two methods, one argument each:
183
+
184
+ - **`select(key)`** steps into a fixed child. The key is typed, autocompleted, and checked at compile time.
185
+ - **`at(id)`** steps into a lookup. The id is runtime data.
186
+
187
+ ```tsx
188
+ documentActions.rename('readme', 'README');
189
+
190
+ workspace.select('documents').at('readme') === documents.at('readme'); // true — same store
191
+ ```
192
+
193
+ Use `at` when the id comes from data, like `documents.at(currentDocId)`. For a fixed child you usually already have the reference, and `settings.setKey('fontSize', 16)` does the same thing as `workspace.select('settings').setKey('fontSize', 16)`, so use the shorter one.
194
+
195
+ `.children()` lists a store's child keys. Selecting a key that is not a child throws a `RangeError` (and is a compile error first).
196
+
197
+ ## Nested lookups
198
+
199
+ Children and lookups mix freely and nest as deep as you need. Each document owns a lookup of comments:
200
+
201
+ ```tsx
202
+ function makeDocument(id: string) {
203
+ return createStore({
204
+ title: 'Untitled',
205
+ body: '',
206
+ dirty: false,
207
+ comments: createLookup((commentId: string) =>
208
+ createStore({ author: '', text: '', resolved: false })
209
+ ), // a lookup inside each document
210
+ });
211
+ }
212
+ ```
213
+
214
+ The chain follows the tree: child, id, child, id.
215
+
216
+ ```tsx
217
+ workspace.select('documents').at('readme').select('comments').at('c1').set({ author: 'ada', text: 'Looks good' });
218
+
219
+ workspace.select('documents').at('readme').select('comments').keys(); // ['c1']
220
+ documents.at('readme').select('comments').at('c1').getKey('text'); // 'Looks good'
221
+ ```
222
+
223
+ ## What re-renders
224
+
225
+ The tree is only for organizing state. Re-rendering works like it does in a flat store: writing a key re-renders the components reading that key, and nobody else. Take two small components:
226
+
227
+ ```tsx
228
+ function Title({ id }: { id: string }) {
229
+ const title = useStoreKey(documents.at(id), 'title');
230
+ return <h2>{title}</h2>;
231
+ }
232
+
233
+ function Body({ id }: { id: string }) {
234
+ const body = useStoreKey(documents.at(id), 'body');
235
+ return <article>{body}</article>;
236
+ }
237
+ ```
238
+
239
+ Now edit one document's body and walk through who re-renders:
240
+
241
+ ```tsx
242
+ documentActions.edit('readme', '# Hello'); // writes `body` on the readme document
243
+
244
+ // <Body id="readme" /> re-renders — it reads that key on that document
245
+ // <Title id="readme" /> unchanged — same document, different key
246
+ // <Body id="changelog" /> unchanged — different document
247
+ // anything reading `workspace.name` etc. — unchanged; a write inside a child never touches the parent
248
+ ```
@@ -0,0 +1,145 @@
1
+ # Core concepts
2
+
3
+ How Voltix decides what re-renders: subscriptions are per key, the state object keeps one identity, writes that change nothing are skipped, and writes can be grouped.
4
+
5
+ - [Getting started](./getting-started.md)
6
+ - [Composition](./composition.md)
7
+ - [API reference](./api-reference.md)
8
+ - [Guides](./guides.md)
9
+ - [Patterns](./patterns.md)
10
+
11
+ ## The vocabulary
12
+
13
+ The words the docs use, in plain terms:
14
+
15
+ **Store.** An object of keys, created with `createStore`. It lives outside React, so any module can import it and read or write it. No provider.
16
+
17
+ **Key.** One named value in a store. Components subscribe to keys. Writing a key re-renders only the components reading that key.
18
+
19
+ **Leaf.** A key holding data: a number, string, boolean, object, or array. Read it with `getKey`, write it with `setKey`.
20
+
21
+ **Child.** A key holding another store or a lookup. The child keeps its own state; you reach it with `select(key)`.
22
+
23
+ **Node.** A store or a lookup. Both bind under a key, and both work with `createActions`.
24
+
25
+ **Lookup.** A store per id, made with `createLookup`. `at(id)` returns the store for that id, creating it on first use. Use it for lists where each row should update on its own.
26
+
27
+ **Action.** A function that writes to a store. `createActions` groups a store's actions in one place and returns them as a plain object.
28
+
29
+ **Contract.** A schema (zod, valibot, arktype) attached to a key with `options.schema`. Every write to that key is validated and a bad write is rejected. Use it on data that comes from outside your code: fetch responses, storage, forms.
30
+
31
+ **Derived key.** A key computed from other keys with `derive`. It updates inside the same write that changed its inputs.
32
+
33
+ ## Fine-grained by key
34
+
35
+ Every key has its own listeners. A write notifies the listeners of the keys that changed and nobody else.
36
+
37
+ ```tsx
38
+ const store = createStore({ theme: 'dark', unread: 3 });
39
+
40
+ function Unread() {
41
+ const unread = useStoreKey(store, 'unread');
42
+ return <span>{unread}</span>;
43
+ }
44
+
45
+ function Theme() {
46
+ const theme = useStoreKey(store, 'theme');
47
+ return <span>{theme}</span>;
48
+ }
49
+
50
+ store.setKey('theme', 'light'); // only <Theme /> re-renders
51
+ store.increment('unread'); // only <Unread /> re-renders
52
+ ```
53
+
54
+ `useStoreSelector` works the same way: it re-renders for its selected keys and ignores every other write. When one `set` changes several keys a component reads, the component re-renders once.
55
+
56
+ ## Identity-stable state
57
+
58
+ `get()` always returns the same object. Writes change the values inside it.
59
+
60
+ ```tsx
61
+ const before = store.get();
62
+ store.setKey('unread', 5);
63
+ const after = store.get();
64
+ before === after; // true, always the same reference
65
+ ```
66
+
67
+ What that means for you:
68
+
69
+ - Reading is free. `getKey` and `get` return live values, no copies.
70
+ - Comparing snapshots of `get()` tells you nothing, because the reference never changes. Subscribe to keys instead.
71
+ - To change an object value, assign a new object. Mutating the current one changes data without telling anyone:
72
+
73
+ ```tsx
74
+ const store = createStore({ user: { name: 'Ada', role: 'admin' } });
75
+
76
+ store.get().user.name = 'Bob'; // mutates in place, no notification
77
+ store.mergeSet('user', { name: 'Bob' }); // new object for `user`, listeners notified
78
+ store.setKey('user', { name: 'Bob', role: 'admin' }); // also notifies
79
+ ```
80
+
81
+ `mergeSet` spreads your patch onto the current value and writes the result, so you skip building the new object yourself. See [`mergeSet`](./api-reference.md#mergeset); to merge without writing, use the [`merge` helper](./api-reference.md#merge-helper).
82
+
83
+ ## Leaves and children
84
+
85
+ What you put under a key decides what the key is. Data makes a **leaf**: normal state you read and write. A store (or a [lookup](./composition.md#lookups-a-store-per-id)) makes a **child**: it keeps its own state, and you reach it with `select(key)`.
86
+
87
+ ```tsx
88
+ const settings = createStore({ theme: 'dark', fontSize: 14 });
89
+
90
+ const workspace = createStore({
91
+ name: 'Personal', // leaf — data
92
+ settings, // child — a store
93
+ });
94
+
95
+ workspace.get(); // { name: 'Personal' } — the workspace's own state
96
+ workspace.select('settings').setKey('fontSize', 16); // the settings data lives in `settings`
97
+ ```
98
+
99
+ This is how one store grows into a tree. See [Composition](./composition.md).
100
+
101
+ ## Equality
102
+
103
+ A write only counts when the value actually changed (`Object.is`). Writing the same value again does nothing: no write, no re-render.
104
+
105
+ Some values are new objects that mean the same thing, like a re-fetched user. Give that key its own equality check at creation and those writes are skipped too:
106
+
107
+ ```tsx
108
+ const store = createStore(
109
+ { user: { id: 1, name: 'Ada' } },
110
+ { equals: { user: (prev, next) => prev.id === next.id } }
111
+ );
112
+ ```
113
+
114
+ ### The compare-by-`.id` pattern
115
+
116
+ A fetch or a form often produces a fresh object for the same entity. Compare by id and the noise disappears:
117
+
118
+ ```tsx
119
+ const store = createStore(
120
+ { user: { id: 1, name: 'Ada' } },
121
+ { equals: { user: (prev, next) => prev.id === next.id } }
122
+ );
123
+
124
+ store.setKey('user', { id: 1, name: 'Ada' }); // renders
125
+ store.setKey('user', { id: 1, name: 'Ada Lovelace' }); // same id, skipped
126
+ ```
127
+
128
+ This runs at write time, so the skipped value never lands and nobody re-renders. To guard one component instead of the store, pass a compare function inside its [`useStoreSelector`](./api-reference.md#usestoreselector) selector.
129
+
130
+ ## Batching
131
+
132
+ `batch` groups several writes into one notification per subscription, after the callback ends. Separate subscriptions using the same callback remain independent.
133
+
134
+ ```tsx
135
+ store.batch(() => {
136
+ store.setKey('count', 10);
137
+ store.increment('count', 5);
138
+ store.setKey('label', 'moved');
139
+ });
140
+ // a component reading count and label re-renders once, not three times
141
+ ```
142
+
143
+ Use `batch` for a loop of writes, or when calling several functions that each write on their own. If you already know the keys, `set({ count: 15, label: 'moved' })` is one update and needs no batch. Nested `batch` calls flush once, at the end of the outermost one.
144
+
145
+ Derived keys commit in the same update as their inputs, so their subscribers wake in the same round. See [derived keys](./guides.md#derived-keys).
@@ -0,0 +1,110 @@
1
+ # Getting started
2
+
3
+ Voltix is a fine-grained state manager for React. You create a store that holds a flat object of keys, and components subscribe to the individual keys they read. Writing one key re-renders only the components reading that key.
4
+
5
+ - [Walkthrough](./walkthrough.md)
6
+ - [Core concepts](./core-concepts.md)
7
+ - [Composition](./composition.md)
8
+ - [API reference](./api-reference.md)
9
+ - [Guides](./guides.md)
10
+ - [Patterns](./patterns.md)
11
+ - [Comparison](./comparison.md)
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm i @wildgrowth/voltix
17
+ ```
18
+
19
+ `react` is the only peer dependency. The package is about 5.0 KB gzipped and ships its own types.
20
+
21
+ ## Your first store
22
+
23
+ Create a store once, at module scope, from an initial state object. It lives outside React: any module can read or write it, and there is no provider to set up.
24
+
25
+ ```tsx
26
+ import { createStore } from '@wildgrowth/voltix';
27
+
28
+ export const store = createStore({
29
+ count: 0,
30
+ step: 1,
31
+ });
32
+ ```
33
+
34
+ Every key in the initial object becomes part of the store's typed shape. Reading or writing a key that does not exist is a compile error.
35
+
36
+ ## Read one key with `useStoreKey`
37
+
38
+ `useStoreKey` subscribes to one key and returns its value. The component re-renders only when that key changes.
39
+
40
+ ```tsx
41
+ import { useStoreKey } from '@wildgrowth/voltix';
42
+ import { store } from './store';
43
+
44
+ function Count() {
45
+ const count = useStoreKey(store, 'count');
46
+ return <span>{count}</span>;
47
+ }
48
+ ```
49
+
50
+ ## Read several keys with `useStoreSelector`
51
+
52
+ For more than one key, `useStoreSelector` takes an array of keys and returns just those keys. The component re-renders when any of them changes.
53
+
54
+ ```tsx
55
+ import { useStoreSelector } from '@wildgrowth/voltix';
56
+ import { store } from './store';
57
+
58
+ function Status() {
59
+ const { count, step } = useStoreSelector(store, ['count', 'step']);
60
+ return <span>{count} (step {step})</span>;
61
+ }
62
+ ```
63
+
64
+ The selector is always an array: key strings, plus `{ key: compareFn }` entries when a key needs its own comparison. See [`useStoreSelector`](./api-reference.md#usestoreselector).
65
+
66
+ ## Pre-bind a hook with `createStoreHook`
67
+
68
+ `createStoreHook` binds the store into a hook once, so components stop passing it around. Types are inferred from the store.
69
+
70
+ ```tsx
71
+ import { createStoreHook } from '@wildgrowth/voltix';
72
+ import { store } from './store';
73
+
74
+ export const useCounter = createStoreHook(store);
75
+
76
+ function Controls() {
77
+ const { count, step } = useCounter(['count', 'step']);
78
+ return <span>{count} / {step}</span>;
79
+ }
80
+ ```
81
+
82
+ ## A complete counter
83
+
84
+ ```tsx
85
+ import { createStore, useStoreKey } from '@wildgrowth/voltix';
86
+
87
+ const store = createStore({ count: 0 });
88
+
89
+ function Counter() {
90
+ const count = useStoreKey(store, 'count');
91
+ return (
92
+ <div>
93
+ <button onClick={() => store.increment('count', -1)}>-</button>
94
+ <span>{count}</span>
95
+ <button onClick={() => store.increment('count')}>+</button>
96
+ </div>
97
+ );
98
+ }
99
+ ```
100
+
101
+ `increment` adds to a numeric key (default amount is `1`). The type system allows it only on keys whose value is a number. Writing to the store from an event handler, an effect, or any module updates every subscribed component.
102
+
103
+ ## Next steps
104
+
105
+ - [Walkthrough](./walkthrough.md) builds one app end to end, from a single store to a composed tree.
106
+ - [Core concepts](./core-concepts.md) explains per-key subscriptions, identity-stable state, equality, and batching.
107
+ - [Composition](./composition.md) covers nesting stores and lookups (a store per id), navigated with `select` and `at`.
108
+ - [Guides](./guides.md) covers actions, contracts, derived keys, middleware, persistence, and reacting outside React.
109
+ - [Patterns](./patterns.md) shows real-world recipes: sending requests, polled live updates, and a live table.
110
+ - [API reference](./api-reference.md) documents every export and every store method.