@xstate/effect 0.1.0-alpha.2

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/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 ADDED
@@ -0,0 +1,611 @@
1
+ # @xstate/effect
2
+
3
+ Effect integration for XState v6.
4
+
5
+ This package is experimental and targets XState v6 alpha and Effect 4 RC.
6
+
7
+ ## Installation
8
+
9
+ <!-- package name and peer dependencies from package.json -->
10
+
11
+ ```bash
12
+ npm install @xstate/effect@alpha xstate@alpha effect@rc
13
+ ```
14
+
15
+ ## Quick start
16
+
17
+ <!-- public API from src/index.ts; actor lifetime from src/createEffectActor.ts -->
18
+
19
+ `createEffectActor` runs an XState actor as an Effect interpreter over pure transitions: each step is `transition(snapshot, event)`, and an Effect fiber owns the mailbox, the timers and the actions it produces. The actor is a scoped resource: it stops when the enclosing `Scope` closes. `fromEffect` turns an Effect into actor logic that a machine can invoke. The machine itself is an ordinary v6 machine.
20
+
21
+ ```ts
22
+ import { Context, Effect, Schema } from 'effect';
23
+ import { createEffectActor, fromEffect, waitFor } from '@xstate/effect';
24
+ import { setup } from 'xstate';
25
+
26
+ class Api extends Context.Service<
27
+ Api,
28
+ {
29
+ readonly fetchUser: (id: string) => Effect.Effect<{ id: string }, Error>;
30
+ }
31
+ >()('@app/Api') {}
32
+
33
+ const fetchUser = fromEffect({
34
+ schemas: {
35
+ input: Schema.Struct({ id: Schema.String }),
36
+ output: Schema.Struct({ id: Schema.String })
37
+ },
38
+ effect: ({ input }) => Api.use((api) => api.fetchUser(input.id))
39
+ });
40
+
41
+ const machine = setup({ actors: { fetchUser } }).createMachine({
42
+ initial: 'loading',
43
+ states: {
44
+ loading: {
45
+ invoke: {
46
+ src: 'fetchUser',
47
+ input: { id: '42' },
48
+ onDone: { target: 'success' },
49
+ onError: { target: 'failure' }
50
+ }
51
+ },
52
+ success: {},
53
+ failure: {}
54
+ }
55
+ });
56
+
57
+ const program = Effect.gen(function* () {
58
+ const actor = yield* createEffectActor(machine);
59
+ const snapshot = yield* waitFor(actor, (s) => s.matches('success'));
60
+ return snapshot.value;
61
+ });
62
+
63
+ await Effect.runPromise(
64
+ Effect.provideService(Effect.scoped(program), Api, {
65
+ fetchUser: (id) => Effect.succeed({ id })
66
+ })
67
+ );
68
+ ```
69
+
70
+ Effect-backed logic must run under `createEffectActor`. Starting it with `createActor` puts the actor in the `error` status because no Effect runtime is available.
71
+
72
+ ### How it runs
73
+
74
+ XState's transition function is pure: `transition(snapshot, event)` returns the next snapshot and the actions to run, without running them. `createEffectActor` drives that function through XState's `createDurable` loop from an Effect fiber and interprets the actions:
75
+
76
+ - the mailbox is an Effect `Queue`, so `actor.send` enqueues and the fiber processes events in order;
77
+ - timers for `after` transitions and delayed events are `Effect.sleep` fibers in the actor's `Scope`, on the Effect `Clock`;
78
+ - declared Effect actions run as forked Effects with the services captured at creation, without blocking the loop;
79
+ - built-in actions (spawn, send, emit, stop) run as XState defines them, and child actors run as live XState actors whose Effects use the same host.
80
+
81
+ The handle it returns is an `EffectActor`. It implements XState's `ActorRef` contract (`send`, `getSnapshot`, `subscribe`, `on`) plus `inspect`, `getPersistedSnapshot` and `stop`, so `useSelector`, this package's actor functions and the inspection APIs accept it. Because the loop runs on a fiber, `send` returns before the event is processed; read the outcome with `waitFor`, `join` or `snapshots` rather than `getSnapshot` right after a send.
82
+
83
+ ## Actors
84
+
85
+ ### Lifetime
86
+
87
+ `createEffectActor(logic, options?)` returns `Effect<Actor, never, R | Scope>`, where `R` is the union of service requirements of the logic (see [Requirements](#requirements)). The actor stops, and every Effect it hosts is interrupted, when the scope closes. Use `Effect.scoped` to close the scope when the program finishes, or run the actor inside a Layer to tie it to the application's lifetime.
88
+
89
+ Effects hosted by the actor see a `Scope` that closes when the actor stops. `Effect.addFinalizer` and `Effect.acquireRelease` inside an actor's Effects release with the actor, not with the Effect that registered them.
90
+
91
+ Provide Layers outside `Effect.scoped`, so their resources are released after the actor has stopped:
92
+
93
+ ```ts
94
+ await Effect.runPromise(program.pipe(Effect.scoped, Effect.provide(AppLayer)));
95
+ ```
96
+
97
+ `actor.stop()` still works. It stops the actor and closes the actor's scope synchronously; the release step of `createEffectActor` waits for the actor scope's finalizers before the enclosing scope continues closing.
98
+
99
+ ### Providing an actor as a service
100
+
101
+ An actor built by `createEffectActor` is a scoped Effect, so `Layer.effect` turns it into a service. The actor starts when the Layer is built and stops when the Layer's scope closes.
102
+
103
+ ```ts
104
+ import { Context, Effect, Layer, ManagedRuntime } from 'effect';
105
+ import {
106
+ createEffectActor,
107
+ send,
108
+ waitFor,
109
+ type EffectActor
110
+ } from '@xstate/effect';
111
+
112
+ class CheckoutActor extends Context.Service<
113
+ CheckoutActor,
114
+ EffectActor<typeof checkoutMachine>
115
+ >()('@app/CheckoutActor') {}
116
+
117
+ const CheckoutActorLayer = Layer.effect(
118
+ CheckoutActor,
119
+ createEffectActor(checkoutMachine)
120
+ );
121
+ ```
122
+
123
+ The Layer's requirements are the machine's requirements, so the services the machine needs are provided the same way as for any other Layer:
124
+
125
+ ```ts
126
+ const AppLayer = CheckoutActorLayer.pipe(Layer.provide(PaymentsLayer));
127
+ ```
128
+
129
+ At an edge that is not itself an Effect, build the Layer once with a `ManagedRuntime` and run individual Effects against it:
130
+
131
+ ```ts
132
+ const runtime = ManagedRuntime.make(AppLayer);
133
+
134
+ const pay = runtime.runPromise(
135
+ Effect.gen(function* () {
136
+ const actor = yield* CheckoutActor;
137
+ yield* send(actor, { type: 'PAY' });
138
+ return yield* waitFor(actor, (s) => s.matches('paid'));
139
+ })
140
+ );
141
+
142
+ // When the process shuts down:
143
+ await runtime.dispose();
144
+ ```
145
+
146
+ `runtime.dispose()` closes the runtime's scope, which stops the actor and releases the Layers it was built from.
147
+
148
+ ### Clock
149
+
150
+ XState timers (`after` transitions and delayed events) use the Effect `Clock` service by default. In production this is the live clock. In tests, `TestClock` from `effect/testing` drives them:
151
+
152
+ ```ts
153
+ import { TestClock } from 'effect/testing';
154
+
155
+ const program = Effect.gen(function* () {
156
+ const actor = yield* createEffectActor(machine);
157
+ yield* TestClock.adjust('5 seconds');
158
+ return actor.getSnapshot().value;
159
+ });
160
+
161
+ await Effect.runPromise(
162
+ program.pipe(Effect.scoped, Effect.provide(TestClock.layer()))
163
+ );
164
+ ```
165
+
166
+ Timers are fibers in the actor's scope, so they are interrupted when the actor stops.
167
+
168
+ ### Requirements
169
+
170
+ `RequirementsFrom<Logic>` is the `R` channel of `createEffectActor`. It collects the requirements of:
171
+
172
+ - actions registered with `setupEffect({ actions })`,
173
+ - actors registered with `setup({ actors })` or `setupEffect({ actors })`,
174
+ - logic used inline as `invoke.src`, at the root or in any state,
175
+ - all of the above inside child machines, whether registered or invoked inline, up to 10 levels of machine nesting.
176
+
177
+ Past 10 levels the type stops recursing and contributes `never`, so requirements introduced deeper than that are not part of `R`. TypeScript then accepts a program that does not provide the service, and the actor fails at runtime when the missing service is requested. Flatten the machine tree, or provide those services explicitly, if a machine nests that deeply.
178
+
179
+ `never` in the `R` channel means "no requirement collected", not "no failure possible". `createEffectActor` is typed `Effect<Actor, never, R | Scope>` and has no typed failures at all. Starting Effect-backed logic outside `createEffectActor`, spawning undeclared Effect logic, and using a service that was not collected are programming errors: they surface as the actor's `error` status, not as an Effect failure. Input rejected by a runtime `validator` is thrown while the actor is created, so `createEffectActor` dies with that error as a defect.
180
+
181
+ Logic passed inline to `enq.spawn` lives inside a transition function body and is not visible to the type. Spawning inline Effect logic is therefore rejected at runtime; spawn a declared actor instead (`enq.spawn(args.actors.worker)`). See [Declared only](#declared-only).
182
+
183
+ ### Observing actors
184
+
185
+ <!-- actor surface from src/actor.ts -->
186
+
187
+ These are free functions that take any XState `ActorRef`, including the `EffectActor` handle, children read from `snapshot.children` and actors created outside this package. A bundler drops the ones a program does not import, and everything that already accepts an XState actor, such as `useSelector`, works on the same handle.
188
+
189
+ | Function | Returns |
190
+ | ---------------------------------------- | ----------------------------------------------------------- |
191
+ | `send(actor, event)` | `Effect<void>` |
192
+ | `snapshots(actor)` | `Stream<Snapshot>`: the current snapshot, then each change |
193
+ | `emitted(actor)` | `Stream<Emitted>`: events emitted with `emit` |
194
+ | `waitFor(actor, predicate)` | `Effect<Snapshot, ActorStoppedError>` |
195
+ | `waitFor(actor, predicate, { timeout })` | `Effect<Snapshot, ActorStoppedError \| Cause.TimeoutError>` |
196
+ | `join(actor)` | `Effect<Output, ErrorFrom<Logic> \| ActorStoppedError>` |
197
+ | `inspect(actor)` | `Stream<InspectionEvent>` |
198
+ | `deadLetters(actor)` | `Stream<DeadLetterInspectionEvent>` |
199
+
200
+ `send` and `waitFor` are dual: each takes the actor first, or returns a function of the actor so it can be piped.
201
+
202
+ ```ts
203
+ yield* send(actor, { type: 'PAY' });
204
+ yield* pipe(actor, send({ type: 'PAY' }));
205
+ ```
206
+
207
+ When `predicate` is a type predicate, `waitFor` narrows its result to the asserted snapshot type:
208
+
209
+ ```ts
210
+ const snapshot = yield* waitFor(
211
+ actor,
212
+ (s): s is typeof s & { status: 'done' } => s.status === 'done'
213
+ );
214
+ ```
215
+
216
+ The `timeout` failure is Effect's `Cause.TimeoutError`, not the `TimeoutError` that `xstate` exports for its own delayed-transition errors.
217
+
218
+ `snapshots` ends when the actor completes. If the actor errors, the stream emits the error snapshot and ends. `waitFor` and `join` fail with `ActorStoppedError` when the actor stops or errors before the awaited result; `join` fails with the actor's typed error when the actor's status is `error`.
219
+
220
+ ```ts
221
+ const program = Effect.gen(function* () {
222
+ const actor = yield* createEffectActor(fetchUser, { input: { id: '42' } });
223
+ const user = yield* join(actor);
224
+ return user.id;
225
+ });
226
+ ```
227
+
228
+ `send` returns `Effect<void>` and never fails. It enqueues the event, like `actor.send(event)`, and XState reports an event it could not deliver as a **dead letter** with a reason: `'stopped'` for a send to a stopped actor, `'invalidEvent'` for a payload the target's schema rejects, and `'internalEvent'` for an internal event type sent from outside its owning actor. A failing `send` could not carry that reason, and a dead letter is not an actor error. Observe them instead:
229
+
230
+ ```ts
231
+ const program = Effect.gen(function* () {
232
+ const actor = yield* createEffectActor(machine);
233
+ yield* Effect.forkScoped(
234
+ Stream.runForEach(deadLetters(actor), (event) =>
235
+ Effect.logWarning(`undelivered ${event.event.type}: ${event.reason}`)
236
+ )
237
+ );
238
+ });
239
+ ```
240
+
241
+ ### Matching states
242
+
243
+ <!-- tagged state view from src/state.ts -->
244
+
245
+ `taggedState(snapshot)` views a machine snapshot as a member of a tagged union, so `Match.tag` and `Match.exhaustive` work on states. `_tag` is the state's dotted path, such as `'checkout.paying'`, and `context` is that state's context, including any per-state context schema declared in `setupEffect({ states })`. `TaggedState<typeof machine>` names the union.
246
+
247
+ ```ts
248
+ import { Match, Stream } from 'effect';
249
+ import { snapshots, taggedState, type TaggedState } from '@xstate/effect';
250
+
251
+ const describe = Match.type<TaggedState<typeof checkoutMachine>>().pipe(
252
+ Match.tag('cart', ({ context }) => `${context.items.length} items`),
253
+ Match.tag('paying', ({ context }) => `paying ${context.paymentId}`),
254
+ Match.tag('done.paid', 'done.declined', ({ _tag }) => _tag),
255
+ Match.exhaustive
256
+ );
257
+
258
+ const labels = snapshots(actor).pipe(Stream.map(taggedState), Stream.map(describe));
259
+ ```
260
+
261
+ A parallel state has no single path. Its tag stops at the parallel state, or is `'(machine)'` when the machine itself is parallel; match on `value` or `snapshot.matches` there. The `state` atom from `createActorAtoms` exposes the same view.
262
+
263
+ ## Atoms
264
+
265
+ <!-- atom surface from src/atom.ts -->
266
+
267
+ `@xstate/effect/atom` exposes an actor through `effect/unstable/reactivity`, so a reactive UI reads it the way it reads any other Effect state. `createActorAtoms(runtime, logic, options?)` takes an `Atom.runtime` whose Layer provides the logic's services and returns:
268
+
269
+ | Atom | Type |
270
+ | ----------- | ------------------------------------------------------------------------------- |
271
+ | `actor` | `Atom<AsyncResult<Actor>>` |
272
+ | `snapshot` | `Atom<AsyncResult<Snapshot>>` |
273
+ | `result` | `Atom<AsyncResult<Snapshot, ErrorFrom<Logic>>>`: a `Failure` once the actor errors. The runtime's own error type is also in every atom's error channel |
274
+ | `send` | `Writable<AsyncResult<void, NotReadyError>, Event>`: set it with an event; `NotReadyError` is exported from `@xstate/effect/atom` |
275
+ | `select(f)` | `Atom<AsyncResult<T>>` derived from `snapshot` |
276
+ | `state` | `Atom<AsyncResult<TaggedState>>`: the snapshot as a tagged union, see [Matching states](#matching-states) |
277
+
278
+ ```ts
279
+ import { Effect, Layer } from 'effect';
280
+ import { Atom, AtomRegistry, AsyncResult } from 'effect/unstable/reactivity';
281
+ import { createActorAtoms } from '@xstate/effect/atom';
282
+
283
+ const runtime = Atom.runtime(
284
+ Layer.succeed(Api, { fetchUser: (id: string) => Effect.succeed({ id }) })
285
+ );
286
+ const user = createActorAtoms(runtime, machine);
287
+ const status = user.select((snapshot) => snapshot.value);
288
+
289
+ const registry = AtomRegistry.make();
290
+ registry.subscribe(
291
+ status,
292
+ (result) => {
293
+ if (AsyncResult.isSuccess(result)) {
294
+ console.log(result.value);
295
+ }
296
+ },
297
+ { immediate: true }
298
+ );
299
+ registry.set(user.send, { type: 'RETRY' });
300
+ ```
301
+
302
+ The actor starts when one of its atoms is first read and stops when nothing reads or mounts them anymore. Results are `AsyncResult` values because the runtime's Layer builds asynchronously. `send` enqueues the event, like `actor.send`; setting it before the runtime is ready records a `NotReadyError` failure instead. `result` is `snapshot` with an errored actor reported as a `Failure`, for error boundaries. Wrap an atom with `Atom.keepAlive` to keep the actor for the registry's lifetime, or build the atoms inside `Atom.family` to get one actor per input.
303
+
304
+ A runtime that does not provide a service the logic requires is a type error on the `runtime` argument.
305
+
306
+ `effect/unstable/reactivity` is an unstable Effect module. This entry point follows it and may change independently of the rest of the package.
307
+
308
+ ## React
309
+
310
+ `useMachine`, `useActor` and `useActorRef` call `createActor` internally, so they cannot start Effect-backed logic. In an Effect application the actor lives in the runtime and React reads it through atoms: `@xstate/effect/atom` builds them and `@effect/atom-react` provides the hooks.
311
+
312
+ ```tsx
313
+ import { Suspense } from 'react';
314
+ import { Atom } from 'effect/unstable/reactivity';
315
+ import { useAtomSet, useAtomSuspense } from '@effect/atom-react';
316
+ import { createActorAtoms } from '@xstate/effect/atom';
317
+
318
+ const runtime = Atom.runtime(AppLayer);
319
+ const checkout = createActorAtoms(runtime, checkoutMachine);
320
+ const status = checkout.select((snapshot) => snapshot.value);
321
+
322
+ function Checkout() {
323
+ const { value } = useAtomSuspense(status);
324
+ const send = useAtomSet(checkout.send);
325
+
326
+ return (
327
+ <button onClick={() => send({ type: 'PAY' })}>{String(value)}</button>
328
+ );
329
+ }
330
+
331
+ export function App() {
332
+ return (
333
+ <Suspense fallback={null}>
334
+ <Checkout />
335
+ </Suspense>
336
+ );
337
+ }
338
+ ```
339
+
340
+ `useAtomSuspense` suspends until the runtime and the actor are ready. `useAtomValue` returns the `AsyncResult` instead, for components that render their own loading state. The actor starts when the first component reads one of its atoms and stops when the last one unmounts. An owner component can hold it with `useAtomMount(checkout.actor)` while children read selectors; pin it with `Atom.keepAlive` when it must outlive the components.
341
+
342
+ Without atoms, create the actor through a `ManagedRuntime` and read it with `useSelector` from `@xstate/react`, which takes an existing actor reference. `actor.send` works directly there.
343
+
344
+ ## Effect actor logic
345
+
346
+ <!-- fromEffect, fromEffectStream, fromEffectEventStream from src/fromEffect.ts -->
347
+
348
+ ### `fromEffect`
349
+
350
+ `fromEffect` accepts an Effect, a function that returns an Effect, or a config object:
351
+
352
+ ```ts
353
+ fromEffect(Effect.succeed('done'));
354
+
355
+ fromEffect(({ input }: { input: string }) => Effect.succeed(input.length));
356
+
357
+ fromEffect({
358
+ id: 'loadUser',
359
+ schemas: {
360
+ input: Schema.Struct({ id: Schema.String }),
361
+ output: Schema.Struct({ id: Schema.String })
362
+ },
363
+ effect: ({ input }) => Api.use((api) => api.fetchUser(input.id))
364
+ });
365
+ ```
366
+
367
+ The function form receives `{ input, self, system, emit }`. `emit` publishes an event that `actor.on(...)` and `emitted(actor)` observe. Either schema may be omitted; the missing type is inferred from the Effect.
368
+
369
+ The actor's result maps from the Effect's exit:
370
+
371
+ - Success: status `done` with the value as `output`.
372
+ - Failure: status `error` with the `E` value as `error`. `invoke.onError` receives it typed through `ErrorFrom`.
373
+ - Defect: status `error` with the squashed cause. Defects are not part of the typed error.
374
+ - Interruption caused by the actor stopping or by the invoking state exiting: no error. The actor is stopped.
375
+ - Interruption from inside the Effect, such as `Effect.interrupt`: status `error` with an `EffectInterruptedError`. An `Effect.timeout` is a failure with `Cause.TimeoutError`, not an interruption; a lost `Effect.race` inside the Effect interrupts only the loser and the actor completes with the winner.
376
+
377
+ Use `Effect.timeout`, `Effect.retry` and the other Effect combinators inside the Effect. `fromEffect` does not add its own options for them.
378
+
379
+ ### `fromEffectStream`
380
+
381
+ `fromEffectStream` exposes the latest stream item as the actor's `context` and reaches `done` when the stream completes. A stream failure puts the actor in the `error` status.
382
+
383
+ ```ts
384
+ fromEffectStream(Stream.make(1, 2, 3));
385
+
386
+ fromEffectStream({
387
+ schemas: { input: Schema.Struct({ topic: Schema.String }) },
388
+ stream: ({ input }) => Stream.fromPubSub(topicPubSub(input.topic))
389
+ });
390
+ ```
391
+
392
+ ### `fromEffectEventStream`
393
+
394
+ `fromEffectEventStream` relays each stream item to the parent machine as an event. It accepts the same forms as `fromEffectStream`.
395
+
396
+ ```ts
397
+ const machine = createMachine({
398
+ context: { seen: 0 },
399
+ schemas: { events: { VALUE: types<{ value: number }>() } },
400
+ initial: 'active',
401
+ states: {
402
+ active: {
403
+ invoke: {
404
+ src: fromEffectEventStream(
405
+ Stream.make({ type: 'VALUE', value: 1 }, { type: 'VALUE', value: 2 })
406
+ )
407
+ },
408
+ on: {
409
+ VALUE: ({ context, event }) => ({
410
+ context: { seen: context.seen + event.value }
411
+ })
412
+ }
413
+ }
414
+ }
415
+ });
416
+ ```
417
+
418
+ ## Effect schemas
419
+
420
+ <!-- schema conversion from src/schema.ts -->
421
+
422
+ `setupEffect` accepts Effect schemas in its `schemas` and `states` options, and in `setupEffect(...).extend(...)`. XState infers the decoded `Schema.Type`; there is no need to call `Schema.toStandardSchemaV1`.
423
+
424
+ ```ts
425
+ import { Effect, Schema } from 'effect';
426
+ import { createEffectActor, setupEffect } from '@xstate/effect';
427
+
428
+ const machine = setupEffect({
429
+ schemas: {
430
+ context: Schema.Struct({ count: Schema.Number }),
431
+ events: {
432
+ ADD: Schema.Struct({ value: Schema.Number })
433
+ }
434
+ }
435
+ }).createMachine({
436
+ context: { count: 0 },
437
+ on: {
438
+ ADD: ({ context, event }) => ({
439
+ context: { count: context.count + event.value }
440
+ })
441
+ }
442
+ });
443
+ ```
444
+
445
+ Standard Schemas remain supported and can be mixed with Effect schemas. Schemas provide types without a `validator`; adding `standardSchemaValidator()` from `xstate/validation` enables runtime assertions.
446
+
447
+ XState validation checks a value but does not replace it with a transformed value. When runtime validation is enabled, TypeScript rejects schemas whose encoded and decoded types differ, such as `Schema.NumberFromString`. Effect schemas used at this validation seam must decode synchronously and without service requirements.
448
+
449
+ ## Effect actions
450
+
451
+ <!-- effect action contract from src/setupEffect.ts; spawn guard from src/internal.ts -->
452
+
453
+ `setupEffect({ actions })` declares actions that return an Effect. An Effect action is fire-and-forget: the transition enqueues it, the transition commits, and the Effect then runs in the actor's Effect context without blocking the actor. It is interrupted when the actor stops. A failure or defect routes to the state's `onError`.
454
+
455
+ In XState v6 an action is a plain function that a transition enqueues with explicit arguments, `enq(args.actions.audit, args)`. `setupEffect` keeps that contract. The function is called synchronously during the transition, and the Effect it returns is the asynchronous boundary.
456
+
457
+ ```ts
458
+ class Audit extends Context.Service<
459
+ Audit,
460
+ { readonly record: (count: number) => Effect.Effect<void> }
461
+ >()('@app/Audit') {}
462
+
463
+ const machine = setupEffect({
464
+ actions: {
465
+ audit: ({ context }) => Audit.use((audit) => audit.record(context.count))
466
+ }
467
+ }).createMachine({
468
+ context: { count: 1 },
469
+ initial: 'active',
470
+ states: {
471
+ active: {
472
+ on: {
473
+ AUDIT: (args, enq) => enq(args.actions.audit, args)
474
+ }
475
+ }
476
+ }
477
+ });
478
+ ```
479
+
480
+ Use Effect actions for work whose result the machine does not need: logging, telemetry, notifications, cache writes. When the result matters, invoke the Effect as an actor with `fromEffect`, so `onDone` and `onError` receive it typed and the state models the wait.
481
+
482
+ An Effect action cannot enqueue: it runs after the transition. Enqueue from the transition function, which has the machine's event types. To hand a result back to the machine from an Effect action, send an event to `self`:
483
+
484
+ ```ts
485
+ const machine = setupEffect({
486
+ schemas: {
487
+ events: {
488
+ SAVE: Schema.Struct({}),
489
+ SYNCED: Schema.Struct({ version: Schema.Number })
490
+ }
491
+ },
492
+ actions: {
493
+ sync: ({ context, self }) =>
494
+ Effect.gen(function* () {
495
+ const version = yield* Api.use((api) => api.sync(context.draft));
496
+ yield* Effect.sync(() => self.send({ type: 'SYNCED', version }));
497
+ })
498
+ }
499
+ }).createMachine({
500
+ context: { draft: '', version: 0 },
501
+ initial: 'active',
502
+ states: {
503
+ active: {
504
+ on: {
505
+ SAVE: (args, enq) => enq(args.actions.sync, args),
506
+ SYNCED: ({ context, event }) => ({
507
+ context: { ...context, version: event.version }
508
+ })
509
+ }
510
+ }
511
+ }
512
+ });
513
+ ```
514
+
515
+ ### Declared only
516
+
517
+ Anything that touches the Effect context must be a declared action or a declared actor. Only declared sources contribute to the actor's requirements, so an inline Effect infers `R = never` and fails at runtime when a service is missing. Declared actions also carry a name for inspection and `machine.provide` overrides.
518
+
519
+ - Register Effect actions with `setupEffect({ actions })` and run them with `enq(args.actions.name, args)`.
520
+ - Register spawned Effect logic with `setup({ actors })` or `setupEffect({ actors })` and spawn it with `enq.spawn(args.actors.name)`. Spawning inline Effect logic is an error at runtime.
521
+ - Effect logic used inline as `invoke.src` runs and contributes to `RequirementsFrom`. Registering it in `actors` gives it a name.
522
+
523
+ > **Warning:** An inline action that returns an Effect does not run it. `enq(() => Effect.log('saved'))` creates the Effect and discards it, because XState only awaits returned promises. Register the action with `setupEffect({ actions })` instead.
524
+
525
+ Effect actions do not block the actor. The actor processes the next event while the Effect runs.
526
+
527
+ ## Persistence
528
+
529
+ `actor.getPersistedSnapshot()` returns a serializable snapshot of the actor's state. It records state, not the progress of a running Effect. Restoring a snapshot into a new interpreter is not supported yet; the durable execution loop this package is built on is the intended path for that, and it is the next piece of work.
530
+
531
+ ## Testing
532
+
533
+ Machines keep their normal test surface, and this package adds Effect-native ways to drive and observe them.
534
+
535
+ Delays run on the Effect `Clock`, so `TestClock` advances `after` transitions and delayed sends without real time passing:
536
+
537
+ ```ts
538
+ import { Effect } from 'effect';
539
+ import { TestClock } from 'effect/testing';
540
+ import { createEffectActor, waitFor } from '@xstate/effect';
541
+
542
+ const test = Effect.gen(function* () {
543
+ const actor = yield* createEffectActor(machine);
544
+ yield* TestClock.adjust('30 seconds');
545
+ yield* waitFor(actor, (s) => s.matches('timedOut'));
546
+ });
547
+
548
+ await Effect.runPromise(
549
+ test.pipe(Effect.scoped, Effect.provide(TestClock.layer()))
550
+ );
551
+ ```
552
+
553
+ Assert with `waitFor` for a state the actor should reach, and with `join` for the actor's final output. Both fail rather than hang when the actor stops first, and `waitFor`'s `timeout` option bounds a test that would otherwise wait forever.
554
+
555
+ Observe with `inspect` for every inspection event and `deadLetters` for events the system could not deliver. A test that ends with no dead letters confirms that every event it sent was accepted.
556
+
557
+ Path generation in `xstate/graph` (`getShortestPaths`, `getSimplePaths`, `createTestModel`) operates on the machine, not on a running actor, so it works on an Effect-backed machine unchanged. Execute the generated paths against an actor from `createEffectActor`.
558
+
559
+ ## Retries and supervision
560
+
561
+ `createEffectActor` is an ordinary scoped Effect, so Effect's retry combinators supervise an actor. Wrap the actor and the work that depends on it in `Effect.scoped`, then retry that unit: each attempt builds a fresh actor and the failed attempt's actor is stopped when its scope closes.
562
+
563
+ ```ts
564
+ import { Effect, Schedule } from 'effect';
565
+ import { createEffectActor, join } from '@xstate/effect';
566
+
567
+ const program = Effect.gen(function* () {
568
+ const actor = yield* createEffectActor(machine);
569
+ return yield* join(actor);
570
+ });
571
+
572
+ const supervised = Effect.retry(Effect.scoped(program), {
573
+ schedule: Schedule.exponential('100 millis'),
574
+ times: 3
575
+ });
576
+ ```
577
+
578
+ `join` fails with the machine's typed error, so the schedule sees the actual failure. Retrying `program` without `Effect.scoped` would reuse the outer scope and leak the actors from failed attempts until that scope closes.
579
+
580
+ ## Errors
581
+
582
+ <!-- error types from src/errors.ts; timeout failure from src/actor.ts -->
583
+
584
+ | Error | Raised by | Fields |
585
+ | ------------------------ | ---------------------------------------------------------------------------------------------- | ------------------------------------------------ |
586
+ | `ActorStoppedError` | `waitFor`, `join` when the actor stops or errors before the awaited result | `actorId: string`, `snapshot: Snapshot<unknown>` |
587
+ | `EffectInterruptedError` | Effect logic interrupted from inside, such as `Effect.interrupt`, reported as `snapshot.error` | `cause: Cause.Cause<never>` |
588
+ | `Cause.TimeoutError` | `waitFor` with `{ timeout }` when no snapshot matches in time | Effect's own error; `_tag: 'TimeoutError'` |
589
+
590
+ Both package errors are `Data.TaggedError` classes, so `Effect.catchTag('ActorStoppedError', …)` matches them.
591
+
592
+ The actor's own failures are not in this table. A `fromEffect` actor reports the Effect's `E` value as `snapshot.error`, typed through `ErrorFrom`, which this package re-exports from `xstate`.
593
+
594
+ A root actor that errors does not throw globally the way `createActor(...).start()` does. Its error is a value: read it with `join`, `waitFor`, the `result` atom, or `subscribe`. An errored actor nobody observes is silent, like a failed forked fiber.
595
+
596
+ ## Tracing
597
+
598
+ <!-- span names and attributes from src/internal.ts and src/fromEffect.ts -->
599
+
600
+ Every Effect the actor hosts runs inside a span:
601
+
602
+ | Span | Covers |
603
+ | ----------------------- | ---------------------------------------- |
604
+ | `fromEffect` | a `fromEffect` actor's Effect |
605
+ | `fromEffectStream` | a `fromEffectStream` actor's stream |
606
+ | `fromEffectEventStream` | a `fromEffectEventStream` actor's stream |
607
+ | `action.<name>` | an Effect action registered as `<name>` |
608
+
609
+ Each span carries the attributes `xstate.actor.id` and `xstate.actor.address`. `id` is the actor's own name. `address` is its `/`-joined path of ids from the root actor, which is stable across persistence and restore, so it identifies the same logical actor across runs.
610
+
611
+ Spans are recorded only when a `Tracer` is provided. Without one they cost nothing and export nothing.