@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,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.
|