compound-workflow 1.8.0 → 1.9.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/README.md +61 -69
- package/package.json +2 -8
- package/scripts/check-pack-readme.mjs +1 -16
- package/scripts/check-workflow-contracts.mjs +34 -44
- package/scripts/install-cli.mjs +273 -555
- package/src/AGENTS.md +59 -192
- package/src/{.agents/agents → agents}/research/best-practices-researcher.md +2 -2
- package/src/{.agents/commands → commands}/assess.md +4 -4
- package/src/commands/install.md +43 -0
- package/src/{.agents/commands → commands}/metrics.md +1 -1
- package/src/commands/workflow-agents.md +101 -0
- package/src/{.agents/commands → commands}/workflow-compound.md +1 -1
- package/src/{.agents/commands → commands}/workflow-plan.md +24 -33
- package/src/commands/workflow-work.md +836 -0
- package/src/{.agents/references → references}/README.md +1 -1
- package/src/{.agents/skills → skills}/capture-skill/SKILL.md +1 -1
- package/src/{.agents/skills → skills}/compound-docs/SKILL.md +6 -6
- package/src/{.agents/skills → skills}/compound-docs/references/yaml-schema.md +2 -2
- package/src/skills/setup-agents/SKILL.md +250 -0
- package/src/skills/standards/SKILL.md +79 -0
- package/src/skills/standards/references/architecture.md +228 -0
- package/src/skills/standards/references/code-quality.md +192 -0
- package/src/skills/standards/references/presentation.md +515 -0
- package/src/skills/standards/references/services.md +172 -0
- package/src/skills/standards/references/state-management.md +936 -0
- package/.claude-plugin/plugin.json +0 -7
- package/.cursor-plugin/plugin.json +0 -20
- package/.cursor-plugin/registration.json +0 -5
- package/scripts/check-version-parity.mjs +0 -36
- package/scripts/generate-platform-artifacts.mjs +0 -230
- package/src/.agents/commands/install.md +0 -51
- package/src/.agents/commands/workflow-work.md +0 -690
- package/src/.agents/registry.json +0 -48
- package/src/.agents/scripts/self-check.mjs +0 -227
- package/src/.agents/scripts/sync-opencode.mjs +0 -362
- package/src/.agents/skills/presentation-composability/SKILL.md +0 -72
- package/src/.agents/skills/react-ddd-mvc-frontend/SKILL.md +0 -51
- package/src/.agents/skills/react-ddd-mvc-frontend/references/feature-structure.md +0 -25
- package/src/.agents/skills/react-ddd-mvc-frontend/references/implementation-principles.md +0 -21
- package/src/.agents/skills/react-ddd-mvc-frontend/references/responsibility-gates.md +0 -41
- package/src/.agents/skills/react-ddd-mvc-frontend/references/source-map.md +0 -11
- package/src/.agents/skills/standards/SKILL.md +0 -747
- package/src/.agents/skills/xstate-actor-orchestration/SKILL.md +0 -197
- package/src/.agents/skills/xstate-actor-orchestration/agents/openai.yaml +0 -4
- package/src/.agents/skills/xstate-actor-orchestration/assets/statecharts/.gitkeep +0 -0
- package/src/.agents/skills/xstate-actor-orchestration/references/actor-system-patterns.md +0 -71
- package/src/.agents/skills/xstate-actor-orchestration/references/event-contracts.md +0 -73
- package/src/.agents/skills/xstate-actor-orchestration/references/functional-domain-patterns.md +0 -53
- package/src/.agents/skills/xstate-actor-orchestration/references/machine-structure-and-tags.md +0 -36
- package/src/.agents/skills/xstate-actor-orchestration/references/react-container-pattern.md +0 -45
- package/src/.agents/skills/xstate-actor-orchestration/references/reliability-observability.md +0 -39
- package/src/.agents/skills/xstate-actor-orchestration/references/skill-validation.md +0 -33
- package/src/.agents/skills/xstate-actor-orchestration/references/source-map.md +0 -44
- package/src/.agents/skills/xstate-actor-orchestration/references/statechart-review-and-signoff.md +0 -59
- package/src/.agents/skills/xstate-actor-orchestration/references/testing-strategy.md +0 -35
- package/src/.agents/skills/xstate-actor-orchestration/scripts/create-statechart-artifact.sh +0 -71
- package/src/.agents/skills/xstate-actor-orchestration/scripts/validate-skill.sh +0 -138
- package/src/generated/opencode.managed.json +0 -115
- /package/src/{.agents/agents → agents}/research/framework-docs-researcher.md +0 -0
- /package/src/{.agents/agents → agents}/research/git-history-analyzer.md +0 -0
- /package/src/{.agents/agents → agents}/research/learnings-researcher.md +0 -0
- /package/src/{.agents/agents → agents}/research/repo-research-analyst.md +0 -0
- /package/src/{.agents/agents → agents}/review/agent-native-reviewer.md +0 -0
- /package/src/{.agents/agents → agents}/review/planning-technical-reviewer.md +0 -0
- /package/src/{.agents/agents → agents}/workflow/bug-reproduction-validator.md +0 -0
- /package/src/{.agents/agents → agents}/workflow/lint.md +0 -0
- /package/src/{.agents/agents → agents}/workflow/spec-flow-analyzer.md +0 -0
- /package/src/{.agents/commands → commands}/test-browser.md +0 -0
- /package/src/{.agents/commands → commands}/workflow-brainstorm.md +0 -0
- /package/src/{.agents/commands → commands}/workflow-review.md +0 -0
- /package/src/{.agents/commands → commands}/workflow-tech-review.md +0 -0
- /package/src/{.agents/commands → commands}/workflow-triage.md +0 -0
- /package/src/{.agents/references → references}/standards/README.md +0 -0
- /package/src/{.agents/skills → skills}/agent-browser/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/audit-traceability/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/brainstorming/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/compound-docs/assets/critical-pattern-template.md +0 -0
- /package/src/{.agents/skills → skills}/compound-docs/assets/resolution-template.md +0 -0
- /package/src/{.agents/skills → skills}/compound-docs/schema.project.yaml +0 -0
- /package/src/{.agents/skills → skills}/compound-docs/schema.yaml +0 -0
- /package/src/{.agents/skills → skills}/data-foundations/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/document-review/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/file-todos/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/file-todos/assets/todo-template.md +0 -0
- /package/src/{.agents/skills → skills}/financial-workflow-integrity/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/git-worktree/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/pii-protection-prisma/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/process-metrics/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/process-metrics/assets/daily-template.md +0 -0
- /package/src/{.agents/skills → skills}/process-metrics/assets/monthly-template.md +0 -0
- /package/src/{.agents/skills → skills}/process-metrics/assets/weekly-template.md +0 -0
- /package/src/{.agents/skills → skills}/technical-review/SKILL.md +0 -0
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
# State Management Reference
|
|
2
|
+
|
|
3
|
+
The controller layer is implemented using **XState v5**. This reference covers all patterns, conventions, and rules for writing correct, maintainable state machines.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
1. [Core Principles](#1-core-principles)
|
|
8
|
+
2. [Setup Pattern](#2-setup-pattern)
|
|
9
|
+
3. [State Nodes](#3-state-nodes)
|
|
10
|
+
4. [Context](#4-context)
|
|
11
|
+
5. [Events](#5-events)
|
|
12
|
+
6. [Guards](#6-guards)
|
|
13
|
+
7. [Actions](#7-actions)
|
|
14
|
+
8. [Actors — invoke vs spawn](#8-actors--invoke-vs-spawn)
|
|
15
|
+
9. [Cross-Machine Communication](#9-cross-machine-communication)
|
|
16
|
+
10. [Effect Runtime Injection](#10-effect-runtime-injection)
|
|
17
|
+
11. [Tags](#11-tags)
|
|
18
|
+
12. [File Organisation](#12-file-organisation)
|
|
19
|
+
13. [v5 API Changes from v4](#13-v5-api-changes-from-v4)
|
|
20
|
+
14. [Quick Check — Common Violations](#14-quick-check--common-violations)
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 1. Core Principles
|
|
25
|
+
|
|
26
|
+
### States represent behaviour, context holds data
|
|
27
|
+
|
|
28
|
+
A state node answers "what is the machine doing right now?"
|
|
29
|
+
Context answers "what data does the machine have?"
|
|
30
|
+
|
|
31
|
+
If you find yourself switching on a context value to determine behaviour — that value should be a state node, not context.
|
|
32
|
+
|
|
33
|
+
### Explicit over implicit
|
|
34
|
+
|
|
35
|
+
Every possible transition is declared in the chart. No hidden control flow, no behaviour that emerges from combinations of context values. If it's not in the chart, it doesn't happen.
|
|
36
|
+
|
|
37
|
+
### State chart over boolean flags
|
|
38
|
+
|
|
39
|
+
Model finite conditions as state nodes, not context booleans. Context booleans that gate behaviour are undeclared states — they make the machine harder to reason about and impossible to visualise.
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
// ❌ State disguised as context
|
|
43
|
+
type Context = {
|
|
44
|
+
isLoading: boolean;
|
|
45
|
+
isOpen: boolean;
|
|
46
|
+
viewMode: "folder" | "search";
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ✅ Modelled as state nodes
|
|
50
|
+
states: {
|
|
51
|
+
idle: {},
|
|
52
|
+
loading: {},
|
|
53
|
+
folderView: {},
|
|
54
|
+
searchView: {},
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 2. Setup Pattern
|
|
61
|
+
|
|
62
|
+
`setup()` is the idiomatic entry point for all machines in v5. Declare all types, guards, actors, and actions here — before the machine definition — so XState can infer and propagate types throughout.
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import { setup, assign, fromPromise, sendTo, enqueueActions } from "xstate";
|
|
66
|
+
import * as playlistSelectionEntity from "src/features/{feature}/domain/entities/PlaylistSelectionEntity";
|
|
67
|
+
|
|
68
|
+
export const playlistMachine = setup({
|
|
69
|
+
types: {} as {
|
|
70
|
+
input: { runtime: typeof AppRuntime };
|
|
71
|
+
context: {
|
|
72
|
+
runtime: typeof AppRuntime;
|
|
73
|
+
playlists: Playlist[];
|
|
74
|
+
activeFolderId: string | null;
|
|
75
|
+
};
|
|
76
|
+
events:
|
|
77
|
+
| { type: "playlist.load"; payload: { folderId: string } }
|
|
78
|
+
| { type: "playlist.sync"; payload: { playlists: Playlist[] } }
|
|
79
|
+
| { type: "playlist.public.selectionChanged"; payload: { ids: string[] } };
|
|
80
|
+
},
|
|
81
|
+
guards: {
|
|
82
|
+
hasPlaylists: ({ context }) =>
|
|
83
|
+
playlistSelectionEntity.hasPlaylists(context.playlists),
|
|
84
|
+
hasAddedPlaylists: ({ context, event }) =>
|
|
85
|
+
playlistSelectionEntity.hasAddedPlaylists({
|
|
86
|
+
current: context.playlists,
|
|
87
|
+
incoming: event.payload.playlists,
|
|
88
|
+
}),
|
|
89
|
+
},
|
|
90
|
+
actions: {
|
|
91
|
+
assignPlaylists: assign({
|
|
92
|
+
playlists: ({ event }) => event.payload.playlists,
|
|
93
|
+
}),
|
|
94
|
+
notifyLoaded: sendTo(
|
|
95
|
+
({ system }) => system.get("notifier"),
|
|
96
|
+
{ type: "notifier.notify", payload: { message: "Playlists loaded" } }
|
|
97
|
+
),
|
|
98
|
+
},
|
|
99
|
+
actors: {
|
|
100
|
+
fetchPlaylists: fromPromise(
|
|
101
|
+
({ input }: { input: { runtime: typeof AppRuntime; folderId: string } }) =>
|
|
102
|
+
input.runtime.runPromise(
|
|
103
|
+
PlaylistService.pipe(Effect.flatMap(svc => svc.fetchPlaylists(input.folderId)))
|
|
104
|
+
)
|
|
105
|
+
),
|
|
106
|
+
},
|
|
107
|
+
}).createMachine({
|
|
108
|
+
context: ({ input }) => ({
|
|
109
|
+
runtime: input.runtime,
|
|
110
|
+
playlists: [],
|
|
111
|
+
activeFolderId: null,
|
|
112
|
+
}),
|
|
113
|
+
// ...
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Everything declared in `setup()` is strongly typed throughout the machine.** Guards, actions, and actors are referenced by string key inside the machine config — XState infers from the `setup()` declaration.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## 3. State Nodes
|
|
122
|
+
|
|
123
|
+
### Every state node requires a comment
|
|
124
|
+
|
|
125
|
+
Every state node must have a comment describing its intent — what it represents in the domain, not what it does mechanically.
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
states: {
|
|
129
|
+
idle: {
|
|
130
|
+
// Waiting for the user to initiate an action. No background work.
|
|
131
|
+
},
|
|
132
|
+
loading: {
|
|
133
|
+
// Fetching playlist data from the backend via the injected runtime.
|
|
134
|
+
// Transitions to loaded on success, error on failure.
|
|
135
|
+
},
|
|
136
|
+
backgroundSync: {
|
|
137
|
+
// Receives external data updates and syncs into context.
|
|
138
|
+
// Runs concurrently with foreground states — neither blocks the other.
|
|
139
|
+
type: "parallel",
|
|
140
|
+
states: { ... }
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Parallel states
|
|
146
|
+
|
|
147
|
+
Use `type: "parallel"` when multiple independent concerns must be active simultaneously. Each region is self-contained — regions do not transition into each other.
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
states: {
|
|
151
|
+
session: {
|
|
152
|
+
// Manages auth and data sync as independent concurrent concerns.
|
|
153
|
+
type: "parallel",
|
|
154
|
+
states: {
|
|
155
|
+
auth: {
|
|
156
|
+
// Tracks authentication state independently of data loading.
|
|
157
|
+
initial: "authenticated",
|
|
158
|
+
states: {
|
|
159
|
+
authenticated: { /* ... */ },
|
|
160
|
+
expired: { /* ... */ },
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
sync: {
|
|
164
|
+
// Polls for data updates independently of auth state.
|
|
165
|
+
initial: "idle",
|
|
166
|
+
states: {
|
|
167
|
+
idle: { /* ... */ },
|
|
168
|
+
polling: { /* ... */ },
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**When to use parallel states vs separate machines:**
|
|
177
|
+
- Use `type: "parallel"` when concerns share context and lifecycle — they start and stop together
|
|
178
|
+
- Use separate invoked machines when concerns are fully independent and need their own context
|
|
179
|
+
- Do not use parallel states for concerns that need to coordinate transitions between regions — that coupling is a signal to rethink the model
|
|
180
|
+
|
|
181
|
+
### Reenter behaviour (`reenter: true`)
|
|
182
|
+
|
|
183
|
+
In v5, transitions to a sibling or descendant state within a compound state are **internal by default** — entry and exit actions on the parent state do not fire. If you need the parent's entry/exit to re-run, you must opt in explicitly with `reenter: true`.
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
states: {
|
|
187
|
+
editing: {
|
|
188
|
+
// User is actively editing a playlist item.
|
|
189
|
+
entry: "resetForm",
|
|
190
|
+
states: {
|
|
191
|
+
idle: {},
|
|
192
|
+
saving: {},
|
|
193
|
+
},
|
|
194
|
+
on: {
|
|
195
|
+
"playlist.reset": {
|
|
196
|
+
target: "editing.idle",
|
|
197
|
+
// ✅ Explicitly re-enters editing — resetForm fires again
|
|
198
|
+
reenter: true,
|
|
199
|
+
},
|
|
200
|
+
"playlist.save": {
|
|
201
|
+
// ❌ Without reenter: true, resetForm does NOT fire on this transition
|
|
202
|
+
target: "editing.saving",
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
This is a common source of bugs when migrating from v4, where external transitions were the default.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## 4. Context
|
|
214
|
+
|
|
215
|
+
Context is appropriate for:
|
|
216
|
+
- **Payloads** — data received from events, passed downstream to containers
|
|
217
|
+
- **Previous values** — snapshots held for diffing/change detection
|
|
218
|
+
- **Derived UI data** — transformed data ready for containers to select
|
|
219
|
+
- **Identifiers** — IDs and references needed across transitions
|
|
220
|
+
- **Actor refs** — parent references passed via `input` for child-to-parent communication
|
|
221
|
+
- **Runtime** — the injected Effect runtime (infrastructure, not domain data)
|
|
222
|
+
|
|
223
|
+
Context is **not** appropriate for:
|
|
224
|
+
- Values you switch on to determine transitions
|
|
225
|
+
- Flags that represent where the machine is or what it's doing
|
|
226
|
+
- Anything with a finite set of values that map to distinct behaviours
|
|
227
|
+
|
|
228
|
+
**Exception — previous values:** Storing previous values for change detection is valid data, not state.
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
type Context = {
|
|
232
|
+
previousHierarchy: Hierarchy | null; // for diffing against incoming value
|
|
233
|
+
};
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**Exception — runtime:** The Effect runtime lives in context as the sanctioned infrastructure exception. It is never switched on for transitions.
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
type Context = {
|
|
240
|
+
runtime: typeof AppRuntime; // injected once, used to call services
|
|
241
|
+
};
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Type-Safe Context Access
|
|
245
|
+
|
|
246
|
+
When types are defined in `setup({ types: { context: ... } })`, destructure directly without casting. XState's `setup()` provides full type inference — trust the types.
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
// Machine with typed context
|
|
250
|
+
export const machine = setup({
|
|
251
|
+
types: {
|
|
252
|
+
context: {} as { sidebarCollapsed: boolean; currentRoute: string },
|
|
253
|
+
},
|
|
254
|
+
}).createMachine({
|
|
255
|
+
context: { sidebarCollapsed: false, currentRoute: "/" },
|
|
256
|
+
// ...
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Container — no casting needed
|
|
260
|
+
const [snapshot] = useMachine(machine);
|
|
261
|
+
const { sidebarCollapsed, currentRoute } = snapshot.context; // Fully typed
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
// ❌ Casting when types are already declared in setup()
|
|
266
|
+
const collapsed = (snapshot.context as { sidebarCollapsed: boolean }).sidebarCollapsed;
|
|
267
|
+
|
|
268
|
+
// ✅ Destructure directly — types flow from setup()
|
|
269
|
+
const { sidebarCollapsed } = snapshot.context;
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## 5. Events
|
|
275
|
+
|
|
276
|
+
### String literal unions, not enums
|
|
277
|
+
|
|
278
|
+
Events are typed as **string literal discriminated unions**. XState v5's type inference in `setup()` flows from the union declared in `types.events`. Enums produce opaque values that break this inference chain and add unnecessary runtime overhead.
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
// ✅ String literal union — inference flows through setup(), zero runtime cost
|
|
282
|
+
setup({
|
|
283
|
+
types: {} as {
|
|
284
|
+
events:
|
|
285
|
+
| { type: "playlist.load"; payload: { folderId: string } }
|
|
286
|
+
| { type: "playlist.sync"; payload: { playlists: Playlist[] } };
|
|
287
|
+
},
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
// ❌ Enum — breaks XState v5 type inference
|
|
291
|
+
enum EventType {
|
|
292
|
+
LOAD = "playlist.load",
|
|
293
|
+
SYNC = "playlist.sync",
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Namespace convention
|
|
298
|
+
|
|
299
|
+
**Internal events** — private to the machine, defined in the controller file:
|
|
300
|
+
```
|
|
301
|
+
{ type: "{controllerNamespace}.{eventName}" }
|
|
302
|
+
|
|
303
|
+
// e.g.
|
|
304
|
+
{ type: "playlist.load" }
|
|
305
|
+
{ type: "playlist.sync" }
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
**Public events** — cross-machine API, defined in `types.ts`:
|
|
309
|
+
```
|
|
310
|
+
{ type: "{controllerNamespace}.public.{eventName}" }
|
|
311
|
+
|
|
312
|
+
// e.g.
|
|
313
|
+
{ type: "playlist.public.selectionChanged" }
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Partial event wildcards
|
|
317
|
+
|
|
318
|
+
Because events follow dot-delimited namespaces, you can handle an entire namespace group with a wildcard (`.*`). This is intentional — the naming convention enables the pattern.
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
on: {
|
|
322
|
+
// Handle all playlist events in one transition
|
|
323
|
+
"playlist.*": {
|
|
324
|
+
actions: "logPlaylistEvent",
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
// More specific transitions are checked first — wildcard is a fallback
|
|
328
|
+
"playlist.load": {
|
|
329
|
+
target: "loading",
|
|
330
|
+
},
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Use wildcards for cross-cutting concerns (logging, analytics, error boundaries) — not as a substitute for declaring explicit transitions.
|
|
335
|
+
|
|
336
|
+
### Public event documentation
|
|
337
|
+
|
|
338
|
+
Public event types in `types.ts` must have JSDoc comments describing when the event is emitted and what a subscriber receives:
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
// types.ts
|
|
342
|
+
export type PublicPlaylistEvent =
|
|
343
|
+
/** Emitted when the active playlist selection changes. Payload contains the updated selection IDs. */
|
|
344
|
+
| { type: "playlist.public.selectionChanged"; payload: { ids: string[] } }
|
|
345
|
+
/** Emitted when playlist data has been synchronised from the backend. */
|
|
346
|
+
| { type: "playlist.public.synced"; payload: { playlists: Playlist[] } };
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## 6. Guards
|
|
352
|
+
|
|
353
|
+
### Declaration
|
|
354
|
+
|
|
355
|
+
Declare all guards in `setup()`. Call entity predicates directly — no wrapper functions:
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
import * as playlistSelectionEntity from "src/features/{feature}/domain/entities/PlaylistSelectionEntity";
|
|
359
|
+
|
|
360
|
+
setup({
|
|
361
|
+
guards: {
|
|
362
|
+
hasAddedPlaylists: ({ context, event }) =>
|
|
363
|
+
playlistSelectionEntity.hasAddedPlaylists({
|
|
364
|
+
current: context.playlists,
|
|
365
|
+
incoming: event.payload.playlists,
|
|
366
|
+
}),
|
|
367
|
+
hasPlaylists: ({ context }) =>
|
|
368
|
+
playlistSelectionEntity.hasPlaylists(context.playlists),
|
|
369
|
+
},
|
|
370
|
+
})
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Guarded transition arrays
|
|
374
|
+
|
|
375
|
+
When a single event can lead to multiple targets depending on conditions, use an array of guarded transitions. The first guard that evaluates to `true` wins. A final entry with no guard is the default.
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
on: {
|
|
379
|
+
"playlist.sync": [
|
|
380
|
+
{
|
|
381
|
+
guard: "hasAddedPlaylists",
|
|
382
|
+
target: "reconciling",
|
|
383
|
+
actions: "assignIncomingPlaylists",
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
guard: "hasPlaylists",
|
|
387
|
+
target: "loaded",
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
// Default — no guard
|
|
391
|
+
target: "empty",
|
|
392
|
+
},
|
|
393
|
+
],
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### Higher-order guards
|
|
398
|
+
|
|
399
|
+
v5 ships `and`, `or`, and `not` combinators for composing guards without inline logic:
|
|
400
|
+
|
|
401
|
+
```typescript
|
|
402
|
+
import { and, or, not } from "xstate";
|
|
403
|
+
|
|
404
|
+
setup({
|
|
405
|
+
guards: {
|
|
406
|
+
isAuthenticated: ({ context }) => context.isAuthenticated,
|
|
407
|
+
hasPermission: ({ context }) => context.role === "editor",
|
|
408
|
+
isReadOnly: ({ context }) => context.accessLevel === "read",
|
|
409
|
+
},
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// In transitions — compose without extra guard definitions
|
|
413
|
+
on: {
|
|
414
|
+
"playlist.edit": {
|
|
415
|
+
guard: and(["isAuthenticated", "hasPermission"]),
|
|
416
|
+
target: "editing",
|
|
417
|
+
},
|
|
418
|
+
"playlist.view": {
|
|
419
|
+
guard: or(["isAuthenticated", not("isReadOnly")]),
|
|
420
|
+
target: "viewing",
|
|
421
|
+
},
|
|
422
|
+
}
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Use combinators to express compound conditions in the chart rather than creating new named guards that just combine others.
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
## 7. Actions
|
|
430
|
+
|
|
431
|
+
### Declaration
|
|
432
|
+
|
|
433
|
+
Declare named actions in `setup()`. Actions referenced by string key inside the machine config are fully typed from `setup()`.
|
|
434
|
+
|
|
435
|
+
```typescript
|
|
436
|
+
setup({
|
|
437
|
+
actions: {
|
|
438
|
+
assignPlaylists: assign({
|
|
439
|
+
playlists: ({ event }) => event.payload.playlists,
|
|
440
|
+
}),
|
|
441
|
+
resetContext: assign({
|
|
442
|
+
playlists: [],
|
|
443
|
+
activeFolderId: null,
|
|
444
|
+
}),
|
|
445
|
+
},
|
|
446
|
+
})
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### Action params
|
|
450
|
+
|
|
451
|
+
Pass typed parameters to named actions using the `params` property. This keeps actions reusable across different transitions without coupling them to specific event shapes:
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
setup({
|
|
455
|
+
actions: {
|
|
456
|
+
// Action accepts typed params — not bound to a specific event
|
|
457
|
+
notifyUser: (_, params: { message: string; level: "info" | "error" }) => {
|
|
458
|
+
// send to notifier system actor
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
// In transitions — pass params alongside the action reference
|
|
464
|
+
on: {
|
|
465
|
+
"playlist.saved": {
|
|
466
|
+
actions: {
|
|
467
|
+
type: "notifyUser",
|
|
468
|
+
params: { message: "Playlist saved", level: "info" },
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
"playlist.error": {
|
|
472
|
+
actions: {
|
|
473
|
+
type: "notifyUser",
|
|
474
|
+
params: { message: "Save failed", level: "error" },
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
}
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
### enqueueActions — conditional action sequences
|
|
481
|
+
|
|
482
|
+
Use `enqueueActions` when a single transition needs to conditionally execute multiple actions in sequence. This replaces the removed `pure()` and `choose()` from v4.
|
|
483
|
+
|
|
484
|
+
```typescript
|
|
485
|
+
import { enqueueActions } from "xstate";
|
|
486
|
+
|
|
487
|
+
setup({
|
|
488
|
+
actions: {
|
|
489
|
+
handlePlaylistSync: enqueueActions(({ enqueue, check, context, event }) => {
|
|
490
|
+
// Always assign the incoming data
|
|
491
|
+
enqueue.assign({
|
|
492
|
+
playlists: event.payload.playlists,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Conditionally notify if something changed
|
|
496
|
+
if (check("hasAddedPlaylists")) {
|
|
497
|
+
enqueue.sendTo(
|
|
498
|
+
({ system }) => system.get("notifier"),
|
|
499
|
+
{ type: "notifier.notify", payload: { message: "New playlists added" } }
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Raise an internal event for further processing
|
|
504
|
+
enqueue.raise({ type: "playlist.reconcile" });
|
|
505
|
+
}),
|
|
506
|
+
},
|
|
507
|
+
})
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
**When to use `enqueueActions`:**
|
|
511
|
+
- A transition needs to run multiple actions where some are conditional
|
|
512
|
+
- You need to mix `assign` with `sendTo` or `raise` in one logical step
|
|
513
|
+
- You need guard-checked branching inside a single action handler
|
|
514
|
+
|
|
515
|
+
**When not to use `enqueueActions`:**
|
|
516
|
+
- The actions are all unconditional — list them in the `actions` array directly
|
|
517
|
+
- The branching should be a guarded transition array instead
|
|
518
|
+
|
|
519
|
+
### assertEvent
|
|
520
|
+
|
|
521
|
+
`assertEvent` is used where XState does not automatically narrow the event type. This occurs in `entry` and `exit` actions — which can be triggered by multiple events — but not in transition-scoped handlers, where the type is already narrowed.
|
|
522
|
+
|
|
523
|
+
```typescript
|
|
524
|
+
// ✅ No assertEvent needed — transition scope narrows the type
|
|
525
|
+
on: {
|
|
526
|
+
"playlist.load": {
|
|
527
|
+
actions: assign({
|
|
528
|
+
activeFolderId: ({ event }) => event.payload.folderId,
|
|
529
|
+
}),
|
|
530
|
+
},
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ✅ assertEvent required — entry can be triggered by multiple events
|
|
534
|
+
entry: ({ event }) => {
|
|
535
|
+
assertEvent(event, "playlist.load");
|
|
536
|
+
console.log(event.payload.folderId);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ❌ Unnecessary — redundant inside a transition-scoped assign
|
|
540
|
+
on: {
|
|
541
|
+
"playlist.load": {
|
|
542
|
+
actions: assign({
|
|
543
|
+
activeFolderId: ({ event }) => {
|
|
544
|
+
assertEvent(event, "playlist.load"); // redundant
|
|
545
|
+
return event.payload.folderId;
|
|
546
|
+
},
|
|
547
|
+
}),
|
|
548
|
+
},
|
|
549
|
+
}
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
---
|
|
553
|
+
|
|
554
|
+
## 8. Actors — invoke vs spawn
|
|
555
|
+
|
|
556
|
+
Choosing between `invoke` and `spawn` is an architectural decision, not a style preference. Getting it wrong produces actors that either die too early or leak indefinitely.
|
|
557
|
+
|
|
558
|
+
### invoke — lifecycle bound to a state
|
|
559
|
+
|
|
560
|
+
Use `invoke` when the actor's work is scoped to a specific state. The actor starts when the state is entered and stops when it is exited. This is the correct choice for Effect service calls.
|
|
561
|
+
|
|
562
|
+
```typescript
|
|
563
|
+
states: {
|
|
564
|
+
loading: {
|
|
565
|
+
// Fetching playlists — actor lives only while in this state
|
|
566
|
+
invoke: {
|
|
567
|
+
src: "fetchPlaylists",
|
|
568
|
+
input: ({ context }) => ({
|
|
569
|
+
runtime: context.runtime,
|
|
570
|
+
folderId: context.activeFolderId,
|
|
571
|
+
}),
|
|
572
|
+
onDone: {
|
|
573
|
+
target: "loaded",
|
|
574
|
+
actions: assign({ playlists: ({ event }) => event.output }),
|
|
575
|
+
},
|
|
576
|
+
onError: {
|
|
577
|
+
target: "error",
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
}
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
### spawn — dynamic, action-driven lifetime
|
|
585
|
+
|
|
586
|
+
Use `spawn` when you need to create actors dynamically at runtime, outside of a specific state's lifecycle. Spawned actors are created by an action, persist until explicitly stopped, and must be stored in context to be referenced later.
|
|
587
|
+
|
|
588
|
+
```typescript
|
|
589
|
+
setup({
|
|
590
|
+
actions: {
|
|
591
|
+
spawnUploadWorker: assign({
|
|
592
|
+
uploadRef: ({ spawn, event }) =>
|
|
593
|
+
spawn("uploadWorker", {
|
|
594
|
+
input: { fileId: event.payload.fileId },
|
|
595
|
+
systemId: `upload-${event.payload.fileId}`,
|
|
596
|
+
}),
|
|
597
|
+
}),
|
|
598
|
+
},
|
|
599
|
+
})
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
### Decision rule
|
|
603
|
+
|
|
604
|
+
| Question | Answer | Use |
|
|
605
|
+
|---|---|---|
|
|
606
|
+
| Does the work belong to a specific state? | Yes | `invoke` |
|
|
607
|
+
| Should the actor stop when the state exits? | Yes | `invoke` |
|
|
608
|
+
| Are you calling an Effect service? | Yes | `invoke` |
|
|
609
|
+
| Do you need a dynamic number of actors? | Yes | `spawn` |
|
|
610
|
+
| Does the actor need to outlive the state that created it? | Yes | `spawn` |
|
|
611
|
+
| Is it a system-wide actor registered with `systemId`? | Yes | `invoke` at root |
|
|
612
|
+
|
|
613
|
+
**`invoke` is the default.** Effect service calls are always state-scoped. Reach for `spawn` only when the use case clearly requires a dynamic, independently-lived actor.
|
|
614
|
+
|
|
615
|
+
---
|
|
616
|
+
|
|
617
|
+
## 9. Cross-Machine Communication
|
|
618
|
+
|
|
619
|
+
### Receptionist pattern — system-wide actors
|
|
620
|
+
|
|
621
|
+
In v5, cross-actor communication uses the native actor system. There is no `broadcast()` function — that was a custom v4 abstraction with no equivalent in v5.
|
|
622
|
+
|
|
623
|
+
When `createActor()` is called on the root machine, an implicit actor system is created. Any actor invoked with a `systemId` is registered and can be looked up by any actor in the system via `system.get('systemId')`.
|
|
624
|
+
|
|
625
|
+
**Step 1 — Register the actor at the root:**
|
|
626
|
+
|
|
627
|
+
```typescript
|
|
628
|
+
export const rootMachine = setup({
|
|
629
|
+
actors: { notifierMachine },
|
|
630
|
+
}).createMachine({
|
|
631
|
+
invoke: {
|
|
632
|
+
src: "notifierMachine",
|
|
633
|
+
systemId: "notifier", // registered system-wide
|
|
634
|
+
},
|
|
635
|
+
// ...
|
|
636
|
+
});
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
**Step 2 — Send to it from any child:**
|
|
640
|
+
|
|
641
|
+
```typescript
|
|
642
|
+
export const playlistMachine = setup({
|
|
643
|
+
actions: {
|
|
644
|
+
notifyPlaylistSaved: sendTo(
|
|
645
|
+
({ system }) => system.get("notifier"),
|
|
646
|
+
{ type: "notifier.notify", payload: { message: "Playlist saved" } }
|
|
647
|
+
),
|
|
648
|
+
},
|
|
649
|
+
}).createMachine({
|
|
650
|
+
on: {
|
|
651
|
+
"playlist.save": {
|
|
652
|
+
actions: "notifyPlaylistSaved",
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
No import of the notifier machine is needed in child machines. The system is the coupling point.
|
|
659
|
+
|
|
660
|
+
### Child-to-parent communication
|
|
661
|
+
|
|
662
|
+
Pass `self` as input when invoking a child machine. The child stores the parent ref in context and uses `sendTo` to communicate back. `sendParent()` is deprecated in v5.
|
|
663
|
+
|
|
664
|
+
```typescript
|
|
665
|
+
// Parent — passes self reference as input
|
|
666
|
+
invoke: {
|
|
667
|
+
src: "childMachine",
|
|
668
|
+
input: ({ self }) => ({ parentRef: self }),
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Child — stores parent ref, communicates via sendTo
|
|
672
|
+
setup({
|
|
673
|
+
types: {} as {
|
|
674
|
+
input: { parentRef: ActorRef<Snapshot<unknown>, ParentEvent> };
|
|
675
|
+
context: { parentRef: ActorRef<Snapshot<unknown>, ParentEvent> };
|
|
676
|
+
},
|
|
677
|
+
}).createMachine({
|
|
678
|
+
context: ({ input }) => ({ parentRef: input.parentRef }),
|
|
679
|
+
on: {
|
|
680
|
+
"child.done": {
|
|
681
|
+
actions: sendTo(
|
|
682
|
+
({ context }) => context.parentRef,
|
|
683
|
+
{ type: "playlist.public.childCompleted" }
|
|
684
|
+
),
|
|
685
|
+
},
|
|
686
|
+
},
|
|
687
|
+
})
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
### Rules
|
|
691
|
+
|
|
692
|
+
- **No `broadcast()`** — removed in v5. Use `systemId` + `system.get()` for fan-out
|
|
693
|
+
- **No `sendParent()`** — deprecated. Pass `self` via `input`, store as `parentRef`, use `sendTo`
|
|
694
|
+
- Register system-wide actors with `systemId` at the root machine only
|
|
695
|
+
- Child machines reach system actors via `system.get('systemId')` — never import actor refs directly
|
|
696
|
+
|
|
697
|
+
---
|
|
698
|
+
|
|
699
|
+
## 10. Effect Runtime Injection
|
|
700
|
+
|
|
701
|
+
The app runtime is provided once via XState v5 `input` at the root actor creation site. The root machine stores it in context and passes it as `input` to invoked service actors. No machine constructs or imports the runtime directly.
|
|
702
|
+
|
|
703
|
+
```typescript
|
|
704
|
+
// src/main.ts — actor creation site
|
|
705
|
+
import { createActor } from "xstate";
|
|
706
|
+
import { AppRuntime } from "src/effects.ts";
|
|
707
|
+
|
|
708
|
+
const actor = createActor(rootMachine, {
|
|
709
|
+
input: { runtime: AppRuntime },
|
|
710
|
+
});
|
|
711
|
+
actor.start();
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
```typescript
|
|
715
|
+
// Root machine — receives runtime via input, stores in context
|
|
716
|
+
setup({
|
|
717
|
+
types: {} as {
|
|
718
|
+
input: { runtime: typeof AppRuntime };
|
|
719
|
+
context: { runtime: typeof AppRuntime };
|
|
720
|
+
},
|
|
721
|
+
}).createMachine({
|
|
722
|
+
context: ({ input }) => ({
|
|
723
|
+
runtime: input.runtime,
|
|
724
|
+
}),
|
|
725
|
+
|
|
726
|
+
states: {
|
|
727
|
+
loading: {
|
|
728
|
+
// Passes runtime into the invoked actor — never calls runtime directly
|
|
729
|
+
invoke: {
|
|
730
|
+
src: "fetchPlaylists",
|
|
731
|
+
input: ({ context }) => ({
|
|
732
|
+
runtime: context.runtime,
|
|
733
|
+
folderId: context.activeFolderId,
|
|
734
|
+
}),
|
|
735
|
+
onDone: {
|
|
736
|
+
target: "loaded",
|
|
737
|
+
actions: assign({ playlists: ({ event }) => event.output }),
|
|
738
|
+
},
|
|
739
|
+
onError: { target: "error" },
|
|
740
|
+
},
|
|
741
|
+
},
|
|
742
|
+
},
|
|
743
|
+
});
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
**`runtime` in context is the sanctioned exception** to "context holds domain data only." It is infrastructure — it is never switched on for transitions and never passed to containers.
|
|
747
|
+
|
|
748
|
+
---
|
|
749
|
+
|
|
750
|
+
## 11. Tags
|
|
751
|
+
|
|
752
|
+
Tags are the public API of the machine — the contract between controller and container. State names are internal implementation detail. Containers read tags; they never read state names.
|
|
753
|
+
|
|
754
|
+
Tags are defined as enums. Unlike events, tags are identifier constants used to check set membership — they are not discriminants in a union type, so enums are appropriate and add clarity here.
|
|
755
|
+
|
|
756
|
+
```typescript
|
|
757
|
+
// In the controller file
|
|
758
|
+
export enum Tags {
|
|
759
|
+
VIEW_MODE_FOLDER = "viewModeFolder",
|
|
760
|
+
VIEW_MODE_SEARCH = "viewModeSearch",
|
|
761
|
+
LOADING = "loading",
|
|
762
|
+
SAVING = "saving",
|
|
763
|
+
}
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
```typescript
|
|
767
|
+
// In the machine — applied to state nodes
|
|
768
|
+
states: {
|
|
769
|
+
folderView: {
|
|
770
|
+
// User is browsing content via the folder hierarchy
|
|
771
|
+
tags: [Tags.VIEW_MODE_FOLDER],
|
|
772
|
+
},
|
|
773
|
+
loading: {
|
|
774
|
+
// Fetching data — UI should show a loading indicator
|
|
775
|
+
tags: [Tags.LOADING],
|
|
776
|
+
},
|
|
777
|
+
}
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
```typescript
|
|
781
|
+
// In the container — reads tags, never state names
|
|
782
|
+
const isFolderView = useSelector(actor, s => s.hasTag(Tags.VIEW_MODE_FOLDER));
|
|
783
|
+
const isLoading = useSelector(actor, s => s.hasTag(Tags.LOADING));
|
|
784
|
+
|
|
785
|
+
// ❌ Never do this — tightly coupled to internal structure
|
|
786
|
+
const isFolderView = useSelector(actor, s => s.matches("viewModeManagement.folderView"));
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
---
|
|
790
|
+
|
|
791
|
+
## 12. File Organisation
|
|
792
|
+
|
|
793
|
+
```
|
|
794
|
+
src/features/{feature}/application/controllers/
|
|
795
|
+
└── {Feature}Controller.ts # Machine, Tags enum, internal event types
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
```
|
|
799
|
+
src/features/{feature}/
|
|
800
|
+
└── types.ts # Public event union types only — create when needed
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
| Concern | Location |
|
|
804
|
+
|---|---|
|
|
805
|
+
| `setup()` + machine definition | Controller file |
|
|
806
|
+
| Tags enum | Controller file |
|
|
807
|
+
| Internal event union type | Controller file |
|
|
808
|
+
| Public event union type | `types.ts` |
|
|
809
|
+
|
|
810
|
+
`types.ts` is only introduced when public events need to be consumed by other controllers. Do not create it preemptively.
|
|
811
|
+
|
|
812
|
+
---
|
|
813
|
+
|
|
814
|
+
## 13. v5 API Changes from v4
|
|
815
|
+
|
|
816
|
+
| v4 | v5 |
|
|
817
|
+
|---|---|
|
|
818
|
+
| `createMachine(config, options)` | `setup({ guards, actors, actions }).createMachine(config)` |
|
|
819
|
+
| `interpret(machine)` | `createActor(machine, { input })` |
|
|
820
|
+
| `service.send()` | `actor.send()` |
|
|
821
|
+
| `cond: 'guardName'` | `guard: 'guardName'` |
|
|
822
|
+
| `assign((ctx, evt) => ...)` | `assign(({ context, event }) => ...)` |
|
|
823
|
+
| `send()` action | `sendTo()` / `raise()` |
|
|
824
|
+
| `sendParent()` | `sendTo(({ context }) => context.parentRef, event)` |
|
|
825
|
+
| `pure()` / `choose()` | `enqueueActions()` |
|
|
826
|
+
| `broadcast()` (custom) | Receptionist pattern — `sendTo(({ system }) => system.get('id'), event)` |
|
|
827
|
+
| Implicit external transitions | Internal by default — use `reenter: true` to re-enter |
|
|
828
|
+
| Enum event types | String literal union event types |
|
|
829
|
+
| `in: '...'` transition property | `guard: stateIn(...)` from `xstate/guards` |
|
|
830
|
+
| `state.history` | Track previous snapshot manually via `actor.subscribe()` |
|
|
831
|
+
| `escalate()` action | Throw directly in actions — errors propagate automatically |
|
|
832
|
+
|
|
833
|
+
---
|
|
834
|
+
|
|
835
|
+
## 14. Quick Check — Common Violations
|
|
836
|
+
|
|
837
|
+
**Using `broadcast()` — removed in v5:**
|
|
838
|
+
```typescript
|
|
839
|
+
// ❌
|
|
840
|
+
broadcast({ type: "panelStack.public.sync", payload: ... })
|
|
841
|
+
|
|
842
|
+
// ✅
|
|
843
|
+
sendTo(({ system }) => system.get("notifier"), { type: "notifier.notify", ... })
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
**Using enum for event types:**
|
|
847
|
+
```typescript
|
|
848
|
+
// ❌ Breaks XState v5 type inference
|
|
849
|
+
enum EventType { LOAD = "playlist.load" }
|
|
850
|
+
on: { [EventType.LOAD]: { ... } }
|
|
851
|
+
|
|
852
|
+
// ✅
|
|
853
|
+
on: { "playlist.load": { ... } }
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
**Using `sendParent()` — deprecated:**
|
|
857
|
+
```typescript
|
|
858
|
+
// ❌
|
|
859
|
+
actions: sendParent({ type: "child.done" })
|
|
860
|
+
|
|
861
|
+
// ✅
|
|
862
|
+
actions: sendTo(({ context }) => context.parentRef, { type: "playlist.public.childCompleted" })
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
**Using `pure()` or `choose()` — removed:**
|
|
866
|
+
```typescript
|
|
867
|
+
// ❌
|
|
868
|
+
actions: pure((context, event) => [
|
|
869
|
+
assign({ playlists: event.payload.playlists }),
|
|
870
|
+
condition ? sendTo("notifier", ...) : undefined,
|
|
871
|
+
])
|
|
872
|
+
|
|
873
|
+
// ✅
|
|
874
|
+
actions: enqueueActions(({ enqueue, check }) => {
|
|
875
|
+
enqueue.assign({ playlists: ({ event }) => event.payload.playlists });
|
|
876
|
+
if (check("hasAddedPlaylists")) {
|
|
877
|
+
enqueue.sendTo(({ system }) => system.get("notifier"), { type: "notifier.notify" });
|
|
878
|
+
}
|
|
879
|
+
})
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
**Expecting parent entry/exit to fire without `reenter: true`:**
|
|
883
|
+
```typescript
|
|
884
|
+
// ❌ resetForm will NOT fire — internal transition by default in v5
|
|
885
|
+
on: {
|
|
886
|
+
"playlist.reset": { target: "editing.idle" }
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// ✅ resetForm fires — explicitly re-enters the parent
|
|
890
|
+
on: {
|
|
891
|
+
"playlist.reset": { target: "editing.idle", reenter: true }
|
|
892
|
+
}
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
**Spawning when invoke is correct:**
|
|
896
|
+
```typescript
|
|
897
|
+
// ❌ spawn leaks — nothing stops this actor when the state exits
|
|
898
|
+
actions: assign({
|
|
899
|
+
ref: ({ spawn }) => spawn("fetchPlaylists", { input: { folderId } }),
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
// ✅ invoke is lifecycle-bound — stops when state exits
|
|
903
|
+
invoke: {
|
|
904
|
+
src: "fetchPlaylists",
|
|
905
|
+
input: ({ context }) => ({ runtime: context.runtime, folderId: context.activeFolderId }),
|
|
906
|
+
}
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
**Boolean flag in context that should be a state node:**
|
|
910
|
+
```typescript
|
|
911
|
+
// ❌
|
|
912
|
+
type Context = { isLoading: boolean; hasError: boolean };
|
|
913
|
+
|
|
914
|
+
// ✅
|
|
915
|
+
states: { idle: {}, loading: {}, error: {} }
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
**Using v4 `cond` and `assign` signatures:**
|
|
919
|
+
```typescript
|
|
920
|
+
// ❌ v4
|
|
921
|
+
{ cond: "hasPlaylists" }
|
|
922
|
+
assign((context, event) => ({ ... }))
|
|
923
|
+
|
|
924
|
+
// ✅ v5
|
|
925
|
+
{ guard: "hasPlaylists" }
|
|
926
|
+
assign(({ context, event }) => ({ ... }))
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
**Targeting state name in container:**
|
|
930
|
+
```typescript
|
|
931
|
+
// ❌
|
|
932
|
+
s.matches("viewModeManagement.folderView")
|
|
933
|
+
|
|
934
|
+
// ✅
|
|
935
|
+
s.hasTag(Tags.VIEW_MODE_FOLDER)
|
|
936
|
+
```
|