@wildgrowth/voltix 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/chunk-HGGX55FR.mjs +1 -0
- package/dist/eslint.d.mts +29 -0
- package/dist/eslint.d.ts +29 -0
- package/dist/eslint.js +1 -0
- package/dist/eslint.mjs +1 -0
- package/dist/index.d.mts +373 -0
- package/dist/index.d.ts +373 -0
- package/dist/index.js +4 -0
- package/dist/index.mjs +4 -0
- package/docs/api-reference.md +804 -0
- package/docs/comparison.md +64 -0
- package/docs/composition.md +248 -0
- package/docs/core-concepts.md +145 -0
- package/docs/getting-started.md +110 -0
- package/docs/guides.md +479 -0
- package/docs/patterns.md +291 -0
- package/docs/walkthrough.md +334 -0
- package/package.json +94 -0
package/docs/guides.md
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
# Guides
|
|
2
|
+
|
|
3
|
+
The store features, one section each. For nesting and lookups see [Composition](./composition.md); for end-to-end recipes (fetching, polling, live tables) see [Patterns](./patterns.md).
|
|
4
|
+
|
|
5
|
+
- [Organizing actions](#organizing-actions)
|
|
6
|
+
- [Contracts with zod](#contracts-with-zod)
|
|
7
|
+
- [Derived keys](#derived-keys)
|
|
8
|
+
- [Middleware](#middleware)
|
|
9
|
+
- [Persistence](#persistence)
|
|
10
|
+
- [Reacting outside React](#reacting-outside-react)
|
|
11
|
+
- [Custom equality](#custom-equality)
|
|
12
|
+
- [Batching](#batching)
|
|
13
|
+
- [Server rendering](#server-rendering)
|
|
14
|
+
|
|
15
|
+
See also [Core concepts](./core-concepts.md) and the [API reference](./api-reference.md).
|
|
16
|
+
|
|
17
|
+
## Organizing actions
|
|
18
|
+
|
|
19
|
+
[`createActions`](./api-reference.md#createactions) collects the functions that write to a store and returns them as a plain object. The store is untouched; actions are normal functions that use it.
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import { createStore, createActions } from '@wildgrowth/voltix';
|
|
23
|
+
|
|
24
|
+
export const counter = createStore({ count: 0 });
|
|
25
|
+
|
|
26
|
+
export const counterActions = createActions(counter, (store) => ({
|
|
27
|
+
increment: () => store.increment('count'),
|
|
28
|
+
add: (amount: number) => store.increment('count', amount),
|
|
29
|
+
reset: () => store.reset(['count']),
|
|
30
|
+
}));
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Call an action from anywhere, and use the store's own methods and hooks as usual:
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import { counter, counterActions } from './counter';
|
|
37
|
+
import { useStoreKey } from '@wildgrowth/voltix';
|
|
38
|
+
|
|
39
|
+
function Counter() {
|
|
40
|
+
const count = useStoreKey(counter, 'count');
|
|
41
|
+
return <button onClick={counterActions.increment}>{count}</button>;
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`define` receives the store, so an action can write several keys in one update:
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
export const canvas = createStore({ x: 0, y: 0, dirty: false });
|
|
49
|
+
|
|
50
|
+
export const canvasActions = createActions(canvas, (store) => ({
|
|
51
|
+
moveTo: (x: number, y: number) => store.set({ x, y, dirty: true }),
|
|
52
|
+
reset: () => store.reset(),
|
|
53
|
+
}));
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`store.set({ x, y, dirty: true })` writes all three keys in one update, so a component reading them re-renders once.
|
|
57
|
+
|
|
58
|
+
`createActions` also works on a lookup. Actions take the id first:
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
export const todos = createLookup((id: string) => ({ text: '', done: false }));
|
|
62
|
+
|
|
63
|
+
export const todoActions = createActions(todos, (lookup) => ({
|
|
64
|
+
add: (id: string, text: string) => lookup.at(id).set({ text }),
|
|
65
|
+
toggle: (id: string) => lookup.at(id).toggle('done'),
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
todoActions.add('t1', 'buy milk');
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Combine sets with object spread (`{ ...baseActions, ...extraActions }`). For a one-off writer, a plain function that uses the store is fine too.
|
|
72
|
+
|
|
73
|
+
## Contracts with zod
|
|
74
|
+
|
|
75
|
+
TypeScript can't check data that arrives at runtime: fetch responses, `localStorage`, URL params, forms. `options.schema` validates writes per key, so bad data is rejected at the store instead of reaching the UI. See [Schema contracts](./api-reference.md#schema-contracts) for the full rules.
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
import { z } from 'zod';
|
|
79
|
+
import { createStore } from '@wildgrowth/voltix';
|
|
80
|
+
|
|
81
|
+
export const profile = createStore(
|
|
82
|
+
{ email: '', age: 0, plan: 'free' as 'free' | 'pro' },
|
|
83
|
+
{
|
|
84
|
+
schema: {
|
|
85
|
+
email: z.string().email(),
|
|
86
|
+
age: z.number().int().min(0).max(130),
|
|
87
|
+
plan: z.enum(['free', 'pro']),
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
An invalid write is dropped and the previous value stands, so a component reading the key never sees a bad state:
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
const data = await res.json();
|
|
97
|
+
profile.set(data); // any key failing its schema is skipped; the valid ones land
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
By default a rejection is logged with the key and the issues:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
[voltix] write to "email" rejected by schema (zod):
|
|
104
|
+
• Invalid email address
|
|
105
|
+
received: 'not-an-email'
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
To show a rejection in the UI instead, register a handler with [`onSchemaError`](./api-reference.md#onschemaerror). It registers after construction, so it can use the finished store and its actions:
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
const profile = createStore(
|
|
112
|
+
{ email: 'a@b.co', emailError: '' },
|
|
113
|
+
{ schema: { email: z.string().email() } }
|
|
114
|
+
);
|
|
115
|
+
const profileActions = createActions(profile, (store) => ({
|
|
116
|
+
flagEmail: (message: string) => store.setKey('emailError', message),
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
profile.onSchemaError((error) => profileActions.flagEmail(error.issues[0].message));
|
|
120
|
+
|
|
121
|
+
profile.setKey('email', 'nope');
|
|
122
|
+
profile.getKey('email'); // 'a@b.co' — the write is still rejected
|
|
123
|
+
profile.getKey('emailError'); // 'Invalid email address'
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The handler gets the failing store as `error.store`, so a store inside a lookup reports itself. While a handler is registered the console logging is off; the returned disposer turns it back on.
|
|
127
|
+
|
|
128
|
+
Initial values are checked too. A bad one is reported and kept — construction never throws — and transforms apply to initial values the same as to writes.
|
|
129
|
+
|
|
130
|
+
Schemas can also normalize values on the way in:
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
const form = createStore(
|
|
134
|
+
{ email: '' },
|
|
135
|
+
{ schema: { email: z.string().trim().toLowerCase() } }
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
form.setKey('email', ' Ada@Example.COM ');
|
|
139
|
+
form.getKey('email'); // 'ada@example.com'
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Validation is synchronous, so keep schemas to shape and range checks. Anything async (a uniqueness check against a server) belongs in the action that performs the request, before it writes.
|
|
143
|
+
|
|
144
|
+
A key typed `T | undefined` can hold `undefined`, and writing it commits and notifies like any other value. `null` and `undefined` are both fine, so pick whichever your types already use and make the schema match:
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
const session = createStore(
|
|
148
|
+
{ user: null as { id: string } | null },
|
|
149
|
+
{ schema: { user: z.object({ id: z.string() }).nullable() } }
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const draft = createStore(
|
|
153
|
+
{ title: undefined as string | undefined },
|
|
154
|
+
{ schema: { title: z.string().min(1).optional() } }
|
|
155
|
+
);
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Derived keys
|
|
159
|
+
|
|
160
|
+
[`derive`](./api-reference.md#derive) computes one key from other keys. The computation runs once immediately to set the starting value, then runs again inside any write that changes an input, committing its result in the same update. Subscribers to the derived key wake only when the computed value changes.
|
|
161
|
+
|
|
162
|
+
```tsx
|
|
163
|
+
const store = createStore({ first: 'Ada', last: 'Lovelace', full: '' });
|
|
164
|
+
|
|
165
|
+
const stop = store.derive('full', ['first', 'last'], ({ first, last }) => `${first} ${last}`);
|
|
166
|
+
|
|
167
|
+
store.setKey('first', 'Grace');
|
|
168
|
+
store.getKey('full'); // 'Grace Lovelace', already committed
|
|
169
|
+
|
|
170
|
+
stop(); // stop deriving
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The derived value commits in the same update as its inputs, so a component reading `full` sees both change in one render. `compute` runs on every write that touches an input; keep it cheap.
|
|
174
|
+
|
|
175
|
+
Derived keys chain. A key derived from another derived key updates in the same pass, as long as you declare them in dependency order:
|
|
176
|
+
|
|
177
|
+
```tsx
|
|
178
|
+
const cart = createStore({ items: [] as { price: number }[], subtotal: 0, total: 0 });
|
|
179
|
+
|
|
180
|
+
cart.derive('subtotal', ['items'], ({ items }) =>
|
|
181
|
+
items.reduce((sum, i) => sum + i.price, 0)
|
|
182
|
+
);
|
|
183
|
+
cart.derive('total', ['subtotal'], ({ subtotal }) => Math.round(subtotal * 1.2));
|
|
184
|
+
|
|
185
|
+
cart.setKey('items', [{ price: 10 }, { price: 5 }]);
|
|
186
|
+
cart.getKey('total'); // 18
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Middleware
|
|
190
|
+
|
|
191
|
+
[`intercept`](./api-reference.md#intercept) runs your function before a write commits. It receives `(update, state)` and answers with its return value: a partial replaces the update, `false` vetoes it, nothing passes it through. Scope it with a second `keys` argument so it only runs for relevant writes.
|
|
192
|
+
|
|
193
|
+
### Validation (veto a write)
|
|
194
|
+
|
|
195
|
+
```tsx
|
|
196
|
+
const account = createStore({ balance: 100 });
|
|
197
|
+
|
|
198
|
+
account.intercept((update) => {
|
|
199
|
+
if (typeof update.balance === 'number' && update.balance < 0) {
|
|
200
|
+
return false; // reject: balance may not go negative
|
|
201
|
+
}
|
|
202
|
+
}, ['balance']);
|
|
203
|
+
|
|
204
|
+
account.setKey('balance', -20); // blocked, balance stays 100
|
|
205
|
+
account.setKey('balance', 40); // allowed
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
For one key with a shape or range rule, use a [contract](#contracts-with-zod). Use `intercept` for rules that involve several keys or depend on current state.
|
|
209
|
+
|
|
210
|
+
### Transform (rewrite the update)
|
|
211
|
+
|
|
212
|
+
```tsx
|
|
213
|
+
const form = createStore({ email: '' });
|
|
214
|
+
|
|
215
|
+
form.intercept((update) => {
|
|
216
|
+
if (typeof update.email === 'string') {
|
|
217
|
+
return { ...update, email: update.email.trim().toLowerCase() };
|
|
218
|
+
}
|
|
219
|
+
}, ['email']);
|
|
220
|
+
|
|
221
|
+
form.setKey('email', ' Ada@Example.COM ');
|
|
222
|
+
form.getKey('email'); // 'ada@example.com'
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Logging (observe and pass through)
|
|
226
|
+
|
|
227
|
+
`intercept` returns a function. Keep it, and call it later when you want the interceptor gone:
|
|
228
|
+
|
|
229
|
+
```tsx
|
|
230
|
+
const stopLogging = account.intercept((update) => {
|
|
231
|
+
console.log('writing', update);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
account.setKey('balance', 60); // logs: writing { balance: 60 }
|
|
235
|
+
account.setKey('balance', 80); // logs: writing { balance: 80 }
|
|
236
|
+
|
|
237
|
+
stopLogging(); // interceptor removed
|
|
238
|
+
account.setKey('balance', 90); // logs nothing
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Once any interceptor is registered, writes through `setKey`, `update`, `increment`, `toggle`, `mergeSet`, and `reset` all run through your interceptors. They run in registration order, a replacement feeds the next one, and one that throws blocks the write and logs the error.
|
|
242
|
+
|
|
243
|
+
## Persistence
|
|
244
|
+
|
|
245
|
+
[`persist`](./api-reference.md#persist) writes selected keys to storage as they change, and [`loadPersistedState`](./api-reference.md#loadpersistedstate) reads them back. Each key is stored under `` `${persistKey}:${keyName}` ``, so keys are written independently and only when they change.
|
|
246
|
+
|
|
247
|
+
### Sync storage (`localStorage`)
|
|
248
|
+
|
|
249
|
+
Seed the initial state from storage at creation, then start persisting:
|
|
250
|
+
|
|
251
|
+
```tsx
|
|
252
|
+
import { createStore, persist, loadPersistedState } from '@wildgrowth/voltix';
|
|
253
|
+
|
|
254
|
+
type State = { theme: 'light' | 'dark'; isLoggedIn: boolean };
|
|
255
|
+
|
|
256
|
+
const persisted = loadPersistedState<State>(localStorage, 'myapp', ['theme', 'isLoggedIn']);
|
|
257
|
+
|
|
258
|
+
const store = createStore<State>({
|
|
259
|
+
theme: 'light',
|
|
260
|
+
isLoggedIn: false,
|
|
261
|
+
...persisted,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
persist<State>(store, localStorage, 'myapp', ['theme', 'isLoggedIn']);
|
|
265
|
+
|
|
266
|
+
store.setKey('theme', 'dark'); // persisted immediately
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
A key you write as `undefined` persists as `undefined` and comes back that way, so it overrides the default it is spread over rather than falling back to it. Guard the seed if a cleared key should return to its default:
|
|
270
|
+
|
|
271
|
+
```tsx
|
|
272
|
+
const persisted = loadPersistedState<State>(localStorage, 'myapp', ['theme']);
|
|
273
|
+
if (persisted.theme === undefined) delete persisted.theme;
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Async storage (React Native `AsyncStorage`)
|
|
277
|
+
|
|
278
|
+
`loadPersistedState` returns a `Promise` for async storage. Create the store with defaults, start persisting, then hydrate when the read resolves:
|
|
279
|
+
|
|
280
|
+
```tsx
|
|
281
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
282
|
+
import { createStore, persist, loadPersistedState } from '@wildgrowth/voltix';
|
|
283
|
+
|
|
284
|
+
type State = { theme: 'light' | 'dark' };
|
|
285
|
+
|
|
286
|
+
const store = createStore<State>({ theme: 'light' });
|
|
287
|
+
|
|
288
|
+
persist<State>(store, AsyncStorage, 'myapp', ['theme']);
|
|
289
|
+
|
|
290
|
+
loadPersistedState<State>(AsyncStorage, 'myapp', ['theme']).then((persisted) => {
|
|
291
|
+
store.set(persisted); // apply saved values once loaded
|
|
292
|
+
});
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Async writes are fire-and-forget, so state updates are never blocked by a slow write. Read failures and write failures are caught and logged rather than thrown.
|
|
296
|
+
|
|
297
|
+
### What survives storage
|
|
298
|
+
|
|
299
|
+
The default format is JSON plus the common non-JSON types: `Date`, `Map`, `Set`, and `undefined` come back as what they were, nesting included. Values persisted by older versions (plain JSON) load unchanged.
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
const filters = createStore({ tags: new Set<string>(), since: new Date(0) });
|
|
303
|
+
persist(filters, localStorage, 'filters', ['tags', 'since']);
|
|
304
|
+
|
|
305
|
+
// after a reload:
|
|
306
|
+
loadPersistedState(localStorage, 'filters', ['tags', 'since']); // { tags: Set, since: Date }
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
For anything beyond that (BigInt, RegExp, class instances), pass a parser — an object with `stringify` and `parse` — as the last argument to both `persist` and `loadPersistedState`. [superjson](https://github.com/flightcontrolhq/superjson) has exactly that shape:
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
import superjson from 'superjson';
|
|
313
|
+
|
|
314
|
+
persist(store, localStorage, 'myapp', ['snapshot'], superjson);
|
|
315
|
+
const persisted = loadPersistedState(localStorage, 'myapp', ['snapshot'], superjson);
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Writing your own is two functions: `stringify` gets a value and returns the string to store, `parse` gets that string back and returns the value. The `PersistParser` type is exported. One that keeps `BigInt`:
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
import { persist, loadPersistedState, type PersistParser } from '@wildgrowth/voltix';
|
|
322
|
+
|
|
323
|
+
const bigintParser: PersistParser = {
|
|
324
|
+
stringify: (value) => JSON.stringify(value, (key, v) =>
|
|
325
|
+
typeof v === 'bigint' ? { $bigint: v.toString() } : v
|
|
326
|
+
),
|
|
327
|
+
parse: (text) => JSON.parse(text, (key, v) =>
|
|
328
|
+
v && typeof v === 'object' && '$bigint' in v ? BigInt(v.$bigint) : v
|
|
329
|
+
),
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
persist(wallet, localStorage, 'wallet', ['balance'], bigintParser);
|
|
333
|
+
const persisted = loadPersistedState(localStorage, 'wallet', ['balance'], bigintParser);
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Use the same parser on both sides. Switching parsers changes the stored format, so treat it like any shape change: bump the prefix.
|
|
337
|
+
|
|
338
|
+
Storage contents can be anything: users edit `localStorage`, old deploys leave old shapes behind. A [contract](#contracts-with-zod) with `.catch()` repairs a corrupt value at construction:
|
|
339
|
+
|
|
340
|
+
```tsx
|
|
341
|
+
const persisted = loadPersistedState<State>(localStorage, 'myapp', ['plan']);
|
|
342
|
+
|
|
343
|
+
const store = createStore(
|
|
344
|
+
{ plan: 'free' as 'free' | 'pro', ...persisted },
|
|
345
|
+
{ schema: { plan: z.enum(['free', 'pro']).catch('free') } }
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
// storage said plan: "EVIL" → store starts as 'free', no crash, no garbage state
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
When the persisted shape changes between releases, change the prefix (`'myapp-v2'`): old keys are never read again and the store starts from defaults.
|
|
352
|
+
|
|
353
|
+
## Reacting outside React
|
|
354
|
+
|
|
355
|
+
[`onChange`](./api-reference.md#onchange) runs a callback when keys change, with the new and previous values. Use it for work outside React: analytics, syncing to a server, driving a non-React view. Several writes in the same tick collapse into one callback with the final values, delivered on a microtask.
|
|
356
|
+
|
|
357
|
+
```tsx
|
|
358
|
+
const store = createStore({ query: '', page: 1 });
|
|
359
|
+
|
|
360
|
+
const off = store.onChange(['query', 'page'], (values, prev) => {
|
|
361
|
+
console.log(`query ${prev.query} -> ${values.query}, page ${prev.page} -> ${values.page}`);
|
|
362
|
+
fetchResults(values.query, values.page);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
store.batch(() => {
|
|
366
|
+
store.setKey('query', 'voltix');
|
|
367
|
+
store.setKey('page', 1);
|
|
368
|
+
});
|
|
369
|
+
// one callback runs with the final values
|
|
370
|
+
|
|
371
|
+
off(); // stop reacting
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
For a plain notification with no values, [`subscribe`](./api-reference.md#subscribe) runs a listener synchronously whenever a key changes and returns an unsubscribe function.
|
|
375
|
+
|
|
376
|
+
## Custom equality
|
|
377
|
+
|
|
378
|
+
A write commits when the new value differs from the current one (`Object.is`). When that test is wrong for a key, declare an equality function in `options.equals`: return `true` to count two values as equal and skip the write. Equality is fixed at creation, like [contracts](#contracts-with-zod).
|
|
379
|
+
|
|
380
|
+
```tsx
|
|
381
|
+
const store = createStore(
|
|
382
|
+
{ user: { id: 1, name: 'Ada' } },
|
|
383
|
+
{ equals: { user: (prev, next) => prev.id === next.id } }
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
store.setKey('user', { id: 1, name: 'Ada Lovelace' }); // same id, skipped
|
|
387
|
+
store.setKey('user', { id: 2, name: 'Grace Hopper' }); // new id, committed
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
To guard one component instead of the store, pass a compare function inside its [`useStoreSelector`](./api-reference.md#usestoreselector) selector:
|
|
391
|
+
|
|
392
|
+
```tsx
|
|
393
|
+
const { tasks } = useStoreSelector(store, [
|
|
394
|
+
{ tasks: (prev, next) => prev.length === next.length },
|
|
395
|
+
]);
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
## Batching
|
|
399
|
+
|
|
400
|
+
When you know the keys, [`set`](./api-reference.md#set) writes them all in one update: `set({ x: 10, y: 20, label: 'moved' })` re-renders a component reading all three exactly once. Reach for [`batch`](./api-reference.md#batch) when several *separate* writes should still notify together, such as a loop, or a call to functions that each write on their own.
|
|
401
|
+
|
|
402
|
+
```tsx
|
|
403
|
+
const board = createStore({
|
|
404
|
+
selected: {} as Record<string, boolean>,
|
|
405
|
+
filter: '',
|
|
406
|
+
results: [] as string[],
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
function selectMany(ids: string[]) {
|
|
410
|
+
board.batch(() => {
|
|
411
|
+
for (const id of ids) {
|
|
412
|
+
board.setKey('selected', { ...board.getKey('selected'), [id]: true });
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
// one re-render for a component reading `selected`, not one per id
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Batching composes with [actions](#organizing-actions). Each action writes on its own; wrap two of them in a `batch` and their writes notify together:
|
|
420
|
+
|
|
421
|
+
```tsx
|
|
422
|
+
const boardActions = createActions(board, (store) => ({
|
|
423
|
+
clearFilters: () => store.set({ filter: '', selected: {} }),
|
|
424
|
+
clearResults: () => store.setKey('results', []),
|
|
425
|
+
}));
|
|
426
|
+
|
|
427
|
+
board.batch(() => {
|
|
428
|
+
boardActions.clearFilters();
|
|
429
|
+
boardActions.clearResults();
|
|
430
|
+
});
|
|
431
|
+
// two actions, three keys written, one notification round
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
Nested `batch` calls join the outer batch and flush once at the end.
|
|
435
|
+
|
|
436
|
+
## Server rendering
|
|
437
|
+
|
|
438
|
+
`useStoreKey` and `useStoreSelector` are SSR-safe: on the server they render the store's current values, and the page hydrates cleanly.
|
|
439
|
+
|
|
440
|
+
One thing to watch. On the server, a module-scope store is **one object shared by every request**: two users hitting the same server share it. Write user data into it during server rendering and one user's data can end up in another user's HTML.
|
|
441
|
+
|
|
442
|
+
So split your state in two:
|
|
443
|
+
|
|
444
|
+
- **Same for everyone** (theme default, feature flags, UI state): safe to keep in stores anywhere.
|
|
445
|
+
- **Per user** (profile, cart, permissions): fetch it on the server, pass it down as props, and write it into the store **on the client**.
|
|
446
|
+
|
|
447
|
+
```tsx
|
|
448
|
+
// profile.ts
|
|
449
|
+
export const profile = createStore({ name: '', plan: 'free', synced: false });
|
|
450
|
+
|
|
451
|
+
export const profileActions = createActions(profile, (store) => ({
|
|
452
|
+
sync: (user: { name: string; plan: string }) => store.set({ ...user, synced: true }),
|
|
453
|
+
}));
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
```tsx
|
|
457
|
+
// server — one fetch, the data rides the page response
|
|
458
|
+
async function Page() {
|
|
459
|
+
const user = await fetchUser();
|
|
460
|
+
return <Profile user={user} />;
|
|
461
|
+
}
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
```tsx
|
|
465
|
+
// client — first paint from props, the store takes over after mount
|
|
466
|
+
'use client';
|
|
467
|
+
import { profile, profileActions } from './profile';
|
|
468
|
+
|
|
469
|
+
function Profile({ user }: { user: { name: string; plan: string } }) {
|
|
470
|
+
useEffect(() => { profileActions.sync(user); }, [user]);
|
|
471
|
+
const { name, synced } = useStoreSelector(profile, ['name', 'synced']);
|
|
472
|
+
// no loading state — both sides show the same data, `synced` only says who holds it
|
|
473
|
+
return <h1>{synced ? name : user.name}</h1>;
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
One trip total: the fetch happens on the server, the props carry the data into the first paint, and after mount the store holds it — mutations and polling flow through the store from there. Keep the store write in the effect: writing during render looks like a shortcut, but client components also render on the server, and that write lands in the shared server store — the leak above.
|
|
478
|
+
|
|
479
|
+
Server actions belong on the write path, not the first load: calling one from a mount effect costs a second round trip for data the page could have carried as props. For mutations they slot right in — a store action calls the server action and writes the result back into the store.
|