@systemfsoftware/xstate-store 0.0.0-stage → 5.0.0-beta.6

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 ADDED
@@ -0,0 +1,7 @@
1
+ # @systemfsoftware/xstate-store
2
+
3
+ # @systemfsoftware/xstate-store@5.0.0-beta.6
4
+
5
+ `createStore` accepts a `warn` option — a `(message: string) => void` sink for non-fatal runtime warnings from the store and its extensions, defaulting to `console.warn`. The `validateSchemas` extension now reports its "no schemas to validate" development warning through this sink, so callers can observe or silence it without patching the process-wide `console`.
6
+
7
+ Add `@systemfsoftware/xstate-store`, the event-driven store, forked from statelyai/xstate's `next` branch at 2146ae26 (MIT). It behaves like upstream's 5.0.0-alpha.5. Under `exactOptionalPropertyTypes`, optional members that can hold `undefined` declare it explicitly. It has no `effect` dependency or peer: the Effect 4.0.1 peer floor applies only to `@systemfsoftware/xstate-effect` and `@systemfsoftware/xstate-test`.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 David Khourshid
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md CHANGED
@@ -1,3 +1,527 @@
1
- # Temporary Holding Version
1
+ # `@xstate/store`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > `@systemfsoftware/xstate-store` is forked from [statelyai/xstate](https://github.com/statelyai/xstate) (`packages/xstate-store`, branch `next` at `2146ae26ebfc7e6a624b3a1f237f9e6ddc30b9f5`), MIT. The text below is upstream's.
4
+
5
+ XState Store is a library for **simple event-based state management**. If you want a state management library that allows you to update a store's state via events, `@xstate/store` is a great option. If you need more complex application logic needs, like state machines/statecharts, effects, communicating actors, and more, consider [using XState instead](https://github.com/statelyai/xstate).
6
+
7
+ - **Extremely simple**: transitions update state via events, just like Redux, Zustand, Pinia, etc.
8
+ - **Extremely small**: less than 1kb minified/gzipped
9
+ - **XState compatible**: use it with (or without) XState, or convert to XState machines when you need to handle more complex logic & effects. `fromStore()` requires XState v6; with XState v5, use `@xstate/store@4`.
10
+ - **Extra type-safe**: great typing out of the box, with strong inference and no awkwardness.
11
+
12
+ > [!NOTE]
13
+ > This readme is written for [TypeScript](#typescript) users. If you are a JavaScript user, just remove the types.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ nix build github:systemfsoftware/xstate#xstate-store
19
+ ```
20
+
21
+ See the root README's [Install](../../README.md#install) section for wiring a catalog entry. Its peer `@systemfsoftware/xstate` comes from the same flake (`#xstate`).
22
+
23
+ ## Quick start
24
+
25
+ ```ts
26
+ import { createStore } from '@xstate/store'
27
+
28
+ export const donutStore = createStore({
29
+ context: {
30
+ donuts: 0,
31
+ favoriteFlavor: 'chocolate',
32
+ },
33
+ on: {
34
+ addDonut: (context) => ({
35
+ ...context,
36
+ donuts: context.donuts + 1,
37
+ }),
38
+ changeFlavor: (context, event: { flavor: string }) => ({
39
+ ...context,
40
+ favoriteFlavor: event.flavor,
41
+ }),
42
+ eatAllDonuts: (context) => ({
43
+ ...context,
44
+ donuts: 0,
45
+ }),
46
+ },
47
+ })
48
+
49
+ donutStore.subscribe((snapshot) => {
50
+ console.log(snapshot.context)
51
+ })
52
+
53
+ // Equivalent to
54
+ // donutStore.send({ type: 'addDonut' });
55
+ donutStore.trigger.addDonut()
56
+ // => { donuts: 1, favoriteFlavor: 'chocolate' }
57
+
58
+ // donutStore.send({
59
+ // type: 'changeFlavor',
60
+ // flavor: 'strawberry' // Strongly-typed!
61
+ // });
62
+ donutStore.trigger.changeFlavor({ flavor: 'strawberry' })
63
+ // => { donuts: 1, favoriteFlavor: 'strawberry' }
64
+ ```
65
+
66
+ ## Checking events
67
+
68
+ <!-- store.can API from packages/xstate-store/src/types.ts -->
69
+
70
+ Use `store.can` to check whether an event is allowed without updating the store:
71
+
72
+ ```ts
73
+ const store = createStore({
74
+ context: { count: 0 },
75
+ on: {
76
+ increment: (context, event: { by: number }) => {
77
+ if (context.count + event.by > 10) {
78
+ return
79
+ }
80
+
81
+ return {
82
+ count: context.count + event.by,
83
+ }
84
+ },
85
+ },
86
+ })
87
+
88
+ store.can.increment({ by: 4 })
89
+ // => true
90
+ ```
91
+
92
+ Returning `undefined` marks the event as not allowed. Returning the same context
93
+ object is still allowed, and transitions that enqueue effects are allowed.
94
+
95
+ ## Persistence
96
+
97
+ <!-- persist and flushStorage behavior from src/persist.ts -->
98
+
99
+ Use the `persist` extension to save state after events are committed. Calling
100
+ `store.can` or the pure `store.transition` method does not schedule writes.
101
+ Writes preserve commit order, including events triggered synchronously by effects
102
+ or subscriptions. A newer eligible event can supersede an older pending write;
103
+ `onDone` runs for writes actually performed. Asynchronous writes execute in event
104
+ order for each store. Call
105
+ `await flushStorage(store)` to write buffered changes and wait for queued writes
106
+ to finish. Synchronous storage adapters continue to write synchronously.
107
+
108
+ With throttling, `pick` runs once per write on the latest buffered context.
109
+ `clearStorage(store)` cancels buffered writes and removes saved data after any
110
+ already queued writes finish; await its result when using asynchronous storage.
111
+ New events sent after clearing can persist new state. For asynchronous storage,
112
+ call `await rehydrateStore(store)` to load saved state; initial read failures are
113
+ reported through `onError`.
114
+
115
+ ```ts
116
+ import { flushStorage, persist } from '@xstate/store/persist'
117
+
118
+ const savedDonutStore = donutStore.with(persist({ name: 'donuts' }))
119
+ savedDonutStore.trigger.addDonut()
120
+ await flushStorage(savedDonutStore)
121
+ ```
122
+
123
+ ### Undo and persistence
124
+
125
+ <!-- snapshot undo restoration behavior from src/undo.ts -->
126
+
127
+ Snapshot-based `undoRedo` restores historical state while preserving live
128
+ extension metadata, including persistence hydration. A custom `restore` callback
129
+ can enqueue events; their updated extension metadata is preserved too. Apply
130
+ `persist` after `undoRedo` when undo and redo themselves should write to storage:
131
+
132
+ ```ts
133
+ import { undoRedo } from '@xstate/store/undo'
134
+
135
+ const undoableDonutStore = donutStore
136
+ .with(undoRedo({ strategy: 'snapshot' }))
137
+ .with(persist({ name: 'undoable-donuts' }))
138
+ ```
139
+
140
+ ## Atom updates and subscriptions
141
+
142
+ <!-- writable updater tracking and subscriber errors from src/atom.ts -->
143
+
144
+ Reads inside a writable atom's `set(previous => next)` updater do not create
145
+ reactive dependencies. Use a computed atom getter to track other atoms instead.
146
+ If a synchronous subscriber throws, other queued subscribers still receive their
147
+ notifications before the first error is rethrown to the caller. The atom's value
148
+ has already changed; later updates continue to notify subscribers normally.
149
+
150
+ ## Async atoms
151
+
152
+ <!-- createAsyncAtom dependency and cancellation behavior from src/atom.ts -->
153
+
154
+ `createAsyncAtom` loads a value lazily and exposes a `pending`, `done`, or
155
+ `error` state. Atoms read synchronously by its getter remain dependencies after
156
+ the request succeeds or fails. When a dependency changes, subscribed async atoms
157
+ reload; otherwise, they reload on the next read. Read dependencies before the
158
+ first `await` to track them.
159
+
160
+ ```ts
161
+ import { createAsyncAtom, createAtom } from '@xstate/store'
162
+
163
+ const userId = createAtom('ada')
164
+ const user = createAsyncAtom(async ({ signal }) => {
165
+ const id = userId.get()
166
+ const response = await fetch(`/users/${id}`, { signal })
167
+ return response.json()
168
+ })
169
+
170
+ user.subscribe((state) => {
171
+ if (state.status === 'done') {
172
+ console.log(state.data)
173
+ }
174
+ })
175
+
176
+ userId.set('grace') // Reloads even after the previous request settled.
177
+ ```
178
+
179
+ Recomputing aborts the previous getter's signal and ignores its stale result.
180
+
181
+ ## Usage with React
182
+
183
+ Import `useSelector` from `@xstate/store-react`. Select the data you want via `useSelector(…)` and send events using `store.send(eventObject)`:
184
+
185
+ ```tsx
186
+ import { useSelector } from '@xstate/store-react'
187
+ import { donutStore } from './donutStore.ts'
188
+
189
+ function DonutCounter() {
190
+ const donutCount = useSelector(donutStore, (state) => state.context.donuts)
191
+
192
+ return (
193
+ <div>
194
+ <button onClick={() => donutStore.send({ type: 'addDonut' })}>
195
+ Add donut ({donutCount})
196
+ </button>
197
+ </div>
198
+ )
199
+ }
200
+ ```
201
+
202
+ ## Usage with SolidJS
203
+
204
+ Import `useSelector` from `@xstate/store-solid`. Select the data you want via `useSelector(…)` and send events using `store.send(eventObject)`:
205
+
206
+ ```tsx
207
+ import { useSelector } from '@xstate/store-solid'
208
+ import { donutStore } from './donutStore.ts'
209
+
210
+ function DonutCounter() {
211
+ const donutCount = useSelector(donutStore, (state) => state.context.donuts)
212
+
213
+ return (
214
+ <div>
215
+ <button onClick={() => donutStore.send({ type: 'addDonut' })}>
216
+ Add donut ({donutCount()})
217
+ </button>
218
+ </div>
219
+ )
220
+ }
221
+ ```
222
+
223
+ ## Usage with Immer
224
+
225
+ XState Store works well with immutable update libraries like [Immer](https://github.com/immerjs/immer) or [Mutative](https://github.com/unadlib/mutative). Use `produce(...)` inside your transition functions:
226
+
227
+ ```ts
228
+ import { createStore } from '@xstate/store'
229
+ import { produce } from 'immer' // or { create } from 'mutative'
230
+
231
+ const donutStore = createStore({
232
+ context: {
233
+ donuts: 0,
234
+ favoriteFlavor: 'chocolate',
235
+ },
236
+ on: {
237
+ addDonut: (context) =>
238
+ produce(context, (draft) => {
239
+ draft.donuts++
240
+ }),
241
+ changeFlavor: (context, event: { flavor: string }) =>
242
+ produce(context, (draft) => {
243
+ draft.favoriteFlavor = event.flavor
244
+ }),
245
+ eatAllDonuts: (context) =>
246
+ produce(context, (draft) => {
247
+ draft.donuts = 0
248
+ }),
249
+ },
250
+ })
251
+ ```
252
+
253
+ If a transition should be unavailable, return `undefined` from the transition
254
+ before calling `produce(...)`:
255
+
256
+ ```ts
257
+ on: {
258
+ eatDonut: ;
259
+ ;((context) => {
260
+ if (context.donuts === 0) {
261
+ return
262
+ }
263
+
264
+ return produce(context, (draft) => {
265
+ draft.donuts--
266
+ })
267
+ })
268
+ }
269
+ ```
270
+
271
+ Immer treats a producer that returns `undefined` the same as a producer that
272
+ does not explicitly return anything. If you need `produce(...)` itself to return
273
+ `undefined`, return Immer's `nothing` token from the producer:
274
+
275
+ ```ts
276
+ import { nothing, produce } from 'immer'
277
+
278
+ on: {
279
+ eatDonut: ;
280
+ ;((context) =>
281
+ produce(context, (draft) => {
282
+ if (draft.donuts === 0) {
283
+ return nothing
284
+ }
285
+
286
+ draft.donuts--
287
+ }))
288
+ }
289
+ ```
290
+
291
+ ## TypeScript
292
+
293
+ XState Store is written in TypeScript and provides full type safety, _without_ you having to specify generic type parameters. The `context` type is inferred from the initial context object, and the event types are inferred from the event object payloads you provide in the transition functions.
294
+
295
+ ```ts
296
+ import { createStore } from '@xstate/store'
297
+
298
+ const donutStore = createStore({
299
+ // Context inferred as:
300
+ // {
301
+ // donuts: number;
302
+ // favoriteFlavor: string;
303
+ // }
304
+ context: {
305
+ donuts: 0,
306
+ favoriteFlavor: 'chocolate',
307
+ },
308
+ on: {
309
+ // Event inferred as:
310
+ // {
311
+ // type: 'changeFlavor';
312
+ // flavor: string;
313
+ // }
314
+ changeFlavor: (context, event: { flavor: string }) => {
315
+ context.favoriteFlavor = event.flavor
316
+ },
317
+ },
318
+ })
319
+
320
+ donutStore.getSnapshot().context.favoriteFlavor // string
321
+ donutStore.get().context.favoriteFlavor // same snapshot, readable/tracked read
322
+
323
+ donutStore.send({
324
+ type: 'changeFlavor', // Strongly-typed from transition key
325
+ flavor: 'strawberry', // Strongly-typed from { flavor: string }
326
+ })
327
+ ```
328
+
329
+ If you want to provide event or emitted-event types explicitly, you can use `schemas` with any library that implements the [Standard Schema](https://standardschema.dev/) interface. Schemas define the store's runtime-readable contract: the shape of its context, accepted events, and emitted events. Store uses schemas for type inference and metadata by default; it does not validate schema-declared values unless you opt in with `validateSchemas()`.
330
+
331
+ ```ts
332
+ import { createStore } from '@xstate/store'
333
+ import { z } from 'zod'
334
+
335
+ const store = createStore({
336
+ schemas: {
337
+ context: z.object({
338
+ donuts: z.number(),
339
+ favoriteFlavor: z.string(),
340
+ }),
341
+ events: {
342
+ changeFlavor: z.object({
343
+ flavor: z.string(),
344
+ }),
345
+ },
346
+ emitted: {
347
+ flavorChanged: z.object({
348
+ flavor: z.string(),
349
+ }),
350
+ },
351
+ },
352
+ context: {
353
+ donuts: 0,
354
+ favoriteFlavor: 'chocolate',
355
+ },
356
+ on: {
357
+ changeFlavor: (context, event, enqueue) => {
358
+ enqueue.emit.flavorChanged({ flavor: event.flavor })
359
+ return {
360
+ ...context,
361
+ favoriteFlavor: event.flavor,
362
+ }
363
+ },
364
+ },
365
+ })
366
+ ```
367
+
368
+ Event and emitted-event schemas describe payload objects. Use an empty object
369
+ schema for events without payload:
370
+
371
+ ```ts
372
+ schemas: {
373
+ events: {
374
+ reset: z.object({})
375
+ },
376
+ emitted: {
377
+ reset: z.object({})
378
+ }
379
+ }
380
+ ```
381
+
382
+ ### Validating schemas
383
+
384
+ Use the `validateSchemas()` extension when the store should validate its schema
385
+ contract at runtime:
386
+
387
+ ```ts
388
+ import { createStore } from '@xstate/store'
389
+ import { validateSchemas } from '@xstate/store/validate'
390
+ import { z } from 'zod'
391
+
392
+ const store = createStore({
393
+ schemas: {
394
+ context: z.object({
395
+ count: z.number(),
396
+ }),
397
+ events: {
398
+ increment: z.object({
399
+ by: z.number(),
400
+ }),
401
+ },
402
+ emitted: {
403
+ increased: z.object({
404
+ by: z.number(),
405
+ }),
406
+ },
407
+ },
408
+ context: { count: 0 },
409
+ on: {
410
+ increment: (context, event, enqueue) => {
411
+ enqueue.emit.increased({ by: event.by })
412
+ return { count: context.count + event.by }
413
+ },
414
+ },
415
+ }).with(validateSchemas())
416
+ ```
417
+
418
+ `validateSchemas()` validates store macrosteps. It validates the event sent to
419
+ the store, the final context after the transition completes, and emitted events
420
+ before any effects execute. Events queued internally with `enqueue.trigger` are
421
+ processed as part of the same macrostep; their payloads are not separately
422
+ validated in this version.
423
+
424
+ Invalid `send(...)`, `trigger.*(...)`, or `transition(...)` calls throw a
425
+ `StoreValidationError`. `store.can.*(...)` always returns a boolean; validation
426
+ errors make it return `false`.
427
+
428
+ By default, unknown events and emitted events throw when the corresponding
429
+ schema map exists. Extension-added event types are treated as known, even when
430
+ they do not have payload schemas. You can opt out:
431
+
432
+ ```ts
433
+ const store = createStore({
434
+ // ...
435
+ }).with(
436
+ validateSchemas({
437
+ unknownEvents: 'ignore',
438
+ unknownEmitted: 'ignore',
439
+ }),
440
+ )
441
+ ```
442
+
443
+ Use `store.getSnapshot()` when you want an explicit snapshot read from the store itself. Use `store.get()` when consuming the store as a `Readable` value in tracked or reactive code.
444
+
445
+ If you want to make the `context` type more specific, you can strongly type the `context` outside of `createStore(…)` and pass it in:
446
+
447
+ ```ts
448
+ import { createStore } from '@xstate/store'
449
+
450
+ interface DonutContext {
451
+ donuts: number
452
+ favoriteFlavor: 'chocolate' | 'strawberry' | 'blueberry'
453
+ }
454
+
455
+ const donutContext: DonutContext = {
456
+ donuts: 0,
457
+ favoriteFlavor: 'chocolate',
458
+ }
459
+
460
+ const donutStore = createStore({
461
+ context: donutContext,
462
+ on: {
463
+ // ... (transitions go here)
464
+ },
465
+ })
466
+ ```
467
+
468
+ ## Effects and Side Effects
469
+
470
+ You can enqueue effects in state transitions using the `enqueue` argument:
471
+
472
+ ```ts
473
+ import { createStore } from '@xstate/store'
474
+
475
+ const store = createStore({
476
+ context: { count: 0 },
477
+ on: {
478
+ incrementDelayed: (context, event, enqueue) => {
479
+ enqueue.effect(async () => {
480
+ await new Promise((resolve) => setTimeout(resolve, 1000))
481
+ store.send({ type: 'increment' })
482
+ })
483
+
484
+ return context
485
+ },
486
+ increment: (context) => ({
487
+ ...context,
488
+ count: context.count + 1,
489
+ }),
490
+ },
491
+ })
492
+ ```
493
+
494
+ ## Emitting Events
495
+
496
+ You can emit events from transitions by declaring them in `schemas.emitted` and using `enqueue.emit`:
497
+
498
+ ```ts
499
+ import { createStore } from '@xstate/store'
500
+ import { z } from 'zod'
501
+
502
+ const store = createStore({
503
+ schemas: {
504
+ emitted: {
505
+ increased: z.object({
506
+ by: z.number(),
507
+ }),
508
+ },
509
+ },
510
+ context: { count: 0 },
511
+ on: {
512
+ inc: (context, event: { by: number }, enqueue) => {
513
+ enqueue.emit.increased({ by: event.by })
514
+
515
+ return {
516
+ ...context,
517
+ count: context.count + event.by,
518
+ }
519
+ },
520
+ },
521
+ })
522
+
523
+ // Listen for emitted events
524
+ store.on('increased', (event) => {
525
+ console.log(`Count increased by ${event.by}`)
526
+ })
527
+ ```