controlled-machine 0.1.3 → 0.2.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 +197 -408
- package/dist/index.cjs +8 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +11 -6
- package/dist/index.d.ts +11 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -5
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +26 -7
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +11 -2
- package/dist/react.d.ts +11 -2
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +27 -8
- package/dist/react.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,514 +1,314 @@
|
|
|
1
1
|
# Controlled Machine
|
|
2
2
|
|
|
3
|
-
A controlled state machine where state lives outside the machine
|
|
3
|
+
A controlled state machine where **state lives outside the machine**.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
import { createMachine } from 'controlled-machine'
|
|
7
|
-
import { useMachine } from 'controlled-machine/react'
|
|
8
|
-
|
|
9
|
-
const machine = createMachine<{
|
|
10
|
-
input: { isOpen: boolean; setIsOpen: (v: boolean) => void }
|
|
11
|
-
events: { OPEN: undefined; CLOSE: undefined }
|
|
12
|
-
actions: 'open' | 'close'
|
|
13
|
-
}>({
|
|
14
|
-
on: {
|
|
15
|
-
OPEN: 'open',
|
|
16
|
-
CLOSE: 'close',
|
|
17
|
-
},
|
|
18
|
-
actions: {
|
|
19
|
-
open: (ctx) => ctx.setIsOpen(true),
|
|
20
|
-
close: (ctx) => ctx.setIsOpen(false),
|
|
21
|
-
},
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
function Dropdown() {
|
|
25
|
-
const [isOpen, setIsOpen] = useState(false)
|
|
26
|
-
const { send } = useMachine(machine, { isOpen, setIsOpen })
|
|
5
|
+
Machine defines **what** happens. Your component owns **the state**.
|
|
27
6
|
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
---
|
|
7
|
+
## The Killer Example
|
|
33
8
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
Controlled Machine maintains the core concepts of state machines (conditional transitions, side effects) while **keeping state external**.
|
|
9
|
+
A reusable dropdown machine — logic lives in the machine, DOM handling in your component:
|
|
37
10
|
|
|
38
11
|
```ts
|
|
39
|
-
//
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
},
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
// Controlled Machine: state is external, machine only defines handlers
|
|
49
|
-
const [isOpen, setIsOpen] = useState(false)
|
|
50
|
-
const { send } = useMachine(machine, { isOpen, setIsOpen })
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
In React, the most powerful pattern is **external state passed via props**. Controlled Machine naturally integrates with this approach.
|
|
54
|
-
|
|
55
|
-
---
|
|
56
|
-
|
|
57
|
-
## Features
|
|
58
|
-
|
|
59
|
-
- **Controlled** — State is managed in React state or props
|
|
60
|
-
- **Conditional handlers** — Branch logic with `when` conditions
|
|
61
|
-
- **State-based structure** — Define different handlers per state
|
|
62
|
-
- **Effects** — Watch value changes, cleanup support, `send` access
|
|
63
|
-
- **Computed** — Derive values from context
|
|
64
|
-
- **Multiple actions** — Execute multiple actions per event
|
|
65
|
-
|
|
66
|
-
---
|
|
67
|
-
|
|
68
|
-
## Installation
|
|
69
|
-
|
|
70
|
-
```bash
|
|
71
|
-
npm install controlled-machine
|
|
72
|
-
# or
|
|
73
|
-
pnpm add controlled-machine
|
|
74
|
-
# or
|
|
75
|
-
yarn add controlled-machine
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
---
|
|
79
|
-
|
|
80
|
-
## Basic Usage
|
|
81
|
-
|
|
82
|
-
### Define a Machine
|
|
83
|
-
|
|
84
|
-
Use `createMachine` to define event handlers.
|
|
85
|
-
|
|
86
|
-
```ts
|
|
87
|
-
import { createMachine } from 'controlled-machine'
|
|
88
|
-
|
|
89
|
-
type Input = {
|
|
90
|
-
isOpen: boolean
|
|
91
|
-
setIsOpen: (v: boolean) => void
|
|
92
|
-
selectedId: string | null
|
|
93
|
-
setSelectedId: (v: string | null) => void
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
type Events = {
|
|
97
|
-
OPEN: undefined
|
|
98
|
-
CLOSE: undefined
|
|
99
|
-
SELECT: { itemId: string }
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const machine = createMachine<{
|
|
103
|
-
input: Input
|
|
104
|
-
events: Events
|
|
105
|
-
actions: 'open' | 'close' | 'select'
|
|
12
|
+
// dropdown-machine.ts — Pure logic, no DOM dependencies
|
|
13
|
+
const dropdownMachine = createMachine<{
|
|
14
|
+
input: { isOpen: boolean; onOpenChange: (v: boolean) => void }
|
|
15
|
+
events: { OPEN: undefined; CLOSE: undefined; TOGGLE: undefined }
|
|
16
|
+
actions: 'open' | 'close' | 'focusTrigger'
|
|
17
|
+
guards: 'isOpen'
|
|
106
18
|
}>({
|
|
107
19
|
on: {
|
|
108
|
-
OPEN: 'open',
|
|
109
|
-
CLOSE: 'close',
|
|
110
|
-
|
|
20
|
+
OPEN: [{ when: 'isOpen', do: [] }, { do: 'open' }],
|
|
21
|
+
CLOSE: [{ when: 'isOpen', do: ['close', 'focusTrigger'] }],
|
|
22
|
+
TOGGLE: [{ when: 'isOpen', do: 'close' }, { do: 'open' }],
|
|
111
23
|
},
|
|
112
24
|
actions: {
|
|
113
|
-
open: (ctx) => ctx.
|
|
114
|
-
close: (ctx) => ctx.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
25
|
+
open: (ctx) => ctx.onOpenChange(true),
|
|
26
|
+
close: (ctx) => ctx.onOpenChange(false),
|
|
27
|
+
focusTrigger: () => {}, // Default: noop
|
|
28
|
+
},
|
|
29
|
+
guards: {
|
|
30
|
+
isOpen: (ctx) => ctx.isOpen,
|
|
119
31
|
},
|
|
120
32
|
})
|
|
121
33
|
```
|
|
122
34
|
|
|
123
|
-
### Send Events
|
|
124
|
-
|
|
125
|
-
Use `useMachine` in React components.
|
|
126
|
-
|
|
127
35
|
```tsx
|
|
128
|
-
|
|
129
|
-
|
|
36
|
+
// Dropdown.tsx — Component provides DOM implementation
|
|
130
37
|
function Dropdown() {
|
|
131
38
|
const [isOpen, setIsOpen] = useState(false)
|
|
132
|
-
const
|
|
39
|
+
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
133
40
|
|
|
134
|
-
const { send } = useMachine(
|
|
135
|
-
isOpen,
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
41
|
+
const { send } = useMachine(dropdownMachine, {
|
|
42
|
+
input: { isOpen, onOpenChange: setIsOpen },
|
|
43
|
+
actions: {
|
|
44
|
+
// Override: provide actual DOM implementation
|
|
45
|
+
focusTrigger: () => triggerRef.current?.focus(),
|
|
46
|
+
},
|
|
139
47
|
})
|
|
140
48
|
|
|
141
49
|
return (
|
|
142
|
-
|
|
143
|
-
<button onClick={() => send('
|
|
50
|
+
<>
|
|
51
|
+
<button ref={triggerRef} onClick={() => send('TOGGLE')}>
|
|
52
|
+
Menu
|
|
53
|
+
</button>
|
|
144
54
|
{isOpen && (
|
|
145
55
|
<ul>
|
|
146
|
-
<li onClick={() => send('
|
|
56
|
+
<li onClick={() => send('CLOSE')}>Item 1</li>
|
|
147
57
|
</ul>
|
|
148
58
|
)}
|
|
149
|
-
|
|
59
|
+
</>
|
|
150
60
|
)
|
|
151
61
|
}
|
|
152
62
|
```
|
|
153
63
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
64
|
+
**Why this matters:**
|
|
65
|
+
- Machine is **pure and testable** — no refs, no DOM
|
|
66
|
+
- Component **owns its state** — React's controlled pattern
|
|
67
|
+
- Override **only what you need** — `focusTrigger` gets real implementation
|
|
68
|
+
- Same machine, **different UIs** — reuse logic across components
|
|
159
69
|
|
|
160
|
-
|
|
70
|
+
---
|
|
161
71
|
|
|
162
|
-
|
|
72
|
+
## Installation
|
|
163
73
|
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
input?: unknown // External data passed to the machine
|
|
167
|
-
events?: Record<string, unknown> // Event name → payload type
|
|
168
|
-
computed?: Record<string, unknown> // Derived values
|
|
169
|
-
actions?: string // Union of action names
|
|
170
|
-
state?: string // Union of state names (for state-based structure)
|
|
171
|
-
}
|
|
74
|
+
```bash
|
|
75
|
+
npm install controlled-machine
|
|
172
76
|
```
|
|
173
77
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
| Property | Description |
|
|
177
|
-
|----------|-------------|
|
|
178
|
-
| `computed` | Functions that derive values from input |
|
|
179
|
-
| `on` | Event → action mappings (global handlers) |
|
|
180
|
-
| `states` | State-specific event handlers |
|
|
181
|
-
| `always` | Rules evaluated on every context change |
|
|
182
|
-
| `effects` | Watch-based side effects |
|
|
183
|
-
| `actions` | Named action implementations |
|
|
78
|
+
---
|
|
184
79
|
|
|
185
|
-
|
|
80
|
+
## Core Concepts
|
|
186
81
|
|
|
187
|
-
|
|
82
|
+
### 1. External State
|
|
188
83
|
|
|
189
|
-
**
|
|
84
|
+
Unlike XState where state lives inside the machine, here **you own the state**:
|
|
190
85
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
| `state` | Current state (if using state-based structure) |
|
|
86
|
+
```tsx
|
|
87
|
+
// Your state, your control
|
|
88
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
89
|
+
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
196
90
|
|
|
197
|
-
|
|
91
|
+
// Machine just defines handlers
|
|
92
|
+
const { send } = useMachine(machine, {
|
|
93
|
+
input: { isOpen, onOpenChange: setIsOpen, selectedId, onSelect: setSelectedId },
|
|
94
|
+
})
|
|
95
|
+
```
|
|
198
96
|
|
|
199
|
-
|
|
97
|
+
### 2. Declarative Handlers
|
|
200
98
|
|
|
201
|
-
|
|
99
|
+
Define **what** happens on each event with conditional logic:
|
|
202
100
|
|
|
203
101
|
```ts
|
|
204
102
|
on: {
|
|
205
|
-
|
|
206
|
-
{ when:
|
|
207
|
-
{ when:
|
|
208
|
-
{ do: '
|
|
103
|
+
SELECT: [
|
|
104
|
+
{ when: 'isDisabled', do: [] }, // Guard: skip if disabled
|
|
105
|
+
{ when: 'hasSelection', do: 'deselect' }, // Conditional action
|
|
106
|
+
{ do: ['select', 'close'] }, // Default: multiple actions
|
|
209
107
|
],
|
|
210
108
|
}
|
|
211
109
|
```
|
|
212
110
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
## Multiple Actions
|
|
111
|
+
### 3. Actions & Guards Override
|
|
216
112
|
|
|
217
|
-
|
|
113
|
+
Machine provides defaults. Component can override:
|
|
218
114
|
|
|
219
115
|
```ts
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
116
|
+
// Machine: default implementations
|
|
117
|
+
const machine = createMachine({
|
|
118
|
+
actions: {
|
|
119
|
+
scrollToItem: () => {}, // noop default
|
|
120
|
+
focusInput: () => {},
|
|
121
|
+
},
|
|
122
|
+
guards: {
|
|
123
|
+
canSelect: (ctx) => !ctx.disabled,
|
|
124
|
+
},
|
|
125
|
+
})
|
|
226
126
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
127
|
+
// Component: real implementations
|
|
128
|
+
useMachine(machine, {
|
|
129
|
+
input: { ... },
|
|
130
|
+
actions: {
|
|
131
|
+
scrollToItem: (ctx) => itemRefs.get(ctx.highlightedId)?.scrollIntoView(),
|
|
132
|
+
focusInput: () => inputRef.current?.focus(),
|
|
133
|
+
},
|
|
134
|
+
guards: {
|
|
135
|
+
canSelect: (ctx) => !ctx.disabled && ctx.items.length > 0,
|
|
136
|
+
},
|
|
137
|
+
})
|
|
233
138
|
```
|
|
234
139
|
|
|
235
140
|
---
|
|
236
141
|
|
|
237
|
-
##
|
|
238
|
-
|
|
239
|
-
Define different handlers per state. Undefined events are ignored.
|
|
240
|
-
|
|
241
|
-
The `state` value can come from either `computed` (recommended) or `input` directly.
|
|
242
|
-
|
|
243
|
-
### Approach 1: Computed State (Recommended)
|
|
142
|
+
## API Reference
|
|
244
143
|
|
|
245
|
-
|
|
144
|
+
### `createMachine<T>(config)`
|
|
246
145
|
|
|
247
146
|
```ts
|
|
248
|
-
// Async data fetching example
|
|
249
147
|
const machine = createMachine<{
|
|
250
|
-
input: {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
setError: (e: Error | null) => void
|
|
257
|
-
}
|
|
258
|
-
events: { FETCH: undefined; RETRY: undefined }
|
|
259
|
-
computed: { state: 'idle' | 'loading' | 'error' | 'success' }
|
|
260
|
-
actions: 'fetch' | 'retry'
|
|
261
|
-
state: 'idle' | 'loading' | 'error' | 'success'
|
|
148
|
+
input: { count: number; setCount: (n: number) => void }
|
|
149
|
+
events: { INCREMENT: undefined; SET: { value: number } }
|
|
150
|
+
computed: { isPositive: boolean }
|
|
151
|
+
actions: 'increment' | 'set'
|
|
152
|
+
guards: 'canIncrement'
|
|
153
|
+
state: 'idle' | 'active' // Optional: for state-based handlers
|
|
262
154
|
}>({
|
|
263
155
|
computed: {
|
|
264
|
-
|
|
265
|
-
if (input.isLoading) return 'loading'
|
|
266
|
-
if (input.error) return 'error'
|
|
267
|
-
if (input.data) return 'success'
|
|
268
|
-
return 'idle'
|
|
269
|
-
},
|
|
156
|
+
isPositive: (input) => input.count > 0,
|
|
270
157
|
},
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
},
|
|
275
|
-
loading: {
|
|
276
|
-
// FETCH ignored while loading
|
|
277
|
-
},
|
|
278
|
-
error: {
|
|
279
|
-
on: { RETRY: 'retry' },
|
|
280
|
-
},
|
|
281
|
-
success: {
|
|
282
|
-
on: { FETCH: 'fetch' }, // Allow refetch
|
|
283
|
-
},
|
|
158
|
+
on: {
|
|
159
|
+
INCREMENT: [{ when: 'canIncrement', do: 'increment' }],
|
|
160
|
+
SET: 'set',
|
|
284
161
|
},
|
|
285
162
|
actions: {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
retry: (ctx) => {
|
|
292
|
-
ctx.setError(null)
|
|
293
|
-
ctx.setIsLoading(true)
|
|
294
|
-
// retry logic...
|
|
295
|
-
},
|
|
163
|
+
increment: (ctx) => ctx.setCount(ctx.count + 1),
|
|
164
|
+
set: (ctx, payload) => ctx.setCount(payload.value),
|
|
165
|
+
},
|
|
166
|
+
guards: {
|
|
167
|
+
canIncrement: (ctx) => ctx.count < 10,
|
|
296
168
|
},
|
|
297
169
|
})
|
|
170
|
+
```
|
|
298
171
|
|
|
299
|
-
|
|
300
|
-
function DataList() {
|
|
301
|
-
const [data, setData] = useState<Item[] | null>(null)
|
|
302
|
-
const [isLoading, setIsLoading] = useState(false)
|
|
303
|
-
const [error, setError] = useState<Error | null>(null)
|
|
172
|
+
### `useMachine(machine, options)`
|
|
304
173
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
174
|
+
```ts
|
|
175
|
+
const { send, computed, state } = useMachine(machine, {
|
|
176
|
+
input: { count, setCount },
|
|
177
|
+
actions: { /* optional overrides */ },
|
|
178
|
+
guards: { /* optional overrides */ },
|
|
179
|
+
})
|
|
308
180
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
{state === 'idle' && <button onClick={() => send('FETCH')}>Load</button>}
|
|
312
|
-
{state === 'loading' && <Spinner />}
|
|
313
|
-
{state === 'error' && <button onClick={() => send('RETRY')}>Retry</button>}
|
|
314
|
-
{state === 'success' && <List items={data!} />}
|
|
315
|
-
</div>
|
|
316
|
-
)
|
|
317
|
-
}
|
|
181
|
+
send('INCREMENT')
|
|
182
|
+
send('SET', { value: 5 })
|
|
318
183
|
```
|
|
319
184
|
|
|
320
|
-
|
|
185
|
+
---
|
|
321
186
|
|
|
322
|
-
|
|
187
|
+
## Features
|
|
323
188
|
|
|
324
|
-
|
|
325
|
-
// Modal with explicit state management
|
|
326
|
-
const machine = createMachine<{
|
|
327
|
-
input: {
|
|
328
|
-
state: 'closed' | 'opening' | 'open' | 'closing'
|
|
329
|
-
setState: (s: 'closed' | 'opening' | 'open' | 'closing') => void
|
|
330
|
-
}
|
|
331
|
-
events: { OPEN: undefined; CLOSE: undefined; ANIMATION_END: undefined }
|
|
332
|
-
actions: 'startOpen' | 'completeOpen' | 'startClose' | 'completeClose'
|
|
333
|
-
state: 'closed' | 'opening' | 'open' | 'closing'
|
|
334
|
-
}>({
|
|
335
|
-
states: {
|
|
336
|
-
closed: {
|
|
337
|
-
on: { OPEN: 'startOpen' },
|
|
338
|
-
},
|
|
339
|
-
opening: {
|
|
340
|
-
on: { ANIMATION_END: 'completeOpen' },
|
|
341
|
-
},
|
|
342
|
-
open: {
|
|
343
|
-
on: { CLOSE: 'startClose' },
|
|
344
|
-
},
|
|
345
|
-
closing: {
|
|
346
|
-
on: { ANIMATION_END: 'completeClose' },
|
|
347
|
-
},
|
|
348
|
-
},
|
|
349
|
-
actions: {
|
|
350
|
-
startOpen: (ctx) => ctx.setState('opening'),
|
|
351
|
-
completeOpen: (ctx) => ctx.setState('open'),
|
|
352
|
-
startClose: (ctx) => ctx.setState('closing'),
|
|
353
|
-
completeClose: (ctx) => ctx.setState('closed'),
|
|
354
|
-
},
|
|
355
|
-
})
|
|
189
|
+
### Conditional Handlers
|
|
356
190
|
|
|
357
|
-
|
|
358
|
-
function Modal() {
|
|
359
|
-
const [state, setState] = useState<'closed' | 'opening' | 'open' | 'closing'>('closed')
|
|
360
|
-
const { send } = useMachine(machine, { state, setState })
|
|
191
|
+
Branch logic with `when`. Stops at first match:
|
|
361
192
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
)
|
|
193
|
+
```ts
|
|
194
|
+
on: {
|
|
195
|
+
TOGGLE: [
|
|
196
|
+
{ when: (ctx) => ctx.disabled, do: [] }, // Function guard
|
|
197
|
+
{ when: 'isOpen', do: 'close' }, // String guard (from guards config)
|
|
198
|
+
{ do: 'open' }, // Default case
|
|
199
|
+
],
|
|
370
200
|
}
|
|
371
201
|
```
|
|
372
202
|
|
|
373
|
-
###
|
|
203
|
+
### Multiple Actions
|
|
374
204
|
|
|
375
|
-
|
|
205
|
+
Execute multiple actions per event:
|
|
376
206
|
|
|
377
207
|
```ts
|
|
378
|
-
{
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
},
|
|
208
|
+
on: {
|
|
209
|
+
SELECT: ['highlight', 'select', 'close'], // Array of actions
|
|
210
|
+
|
|
211
|
+
CONFIRM: [
|
|
212
|
+
{ when: 'isValid', do: ['save', 'close', 'notify'] },
|
|
213
|
+
{ do: 'showError' },
|
|
214
|
+
],
|
|
386
215
|
}
|
|
387
|
-
// idle + LOG → logIdle, then logGlobal
|
|
388
216
|
```
|
|
389
217
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
## Effects
|
|
218
|
+
### Computed Values
|
|
393
219
|
|
|
394
|
-
|
|
220
|
+
Derive values from input:
|
|
395
221
|
|
|
396
222
|
```ts
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
}
|
|
414
|
-
]
|
|
223
|
+
computed: {
|
|
224
|
+
isEmpty: (input) => input.items.length === 0,
|
|
225
|
+
canSubmit: (input) => input.value.length > 0 && !input.isLoading,
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
// Use in handlers
|
|
229
|
+
on: {
|
|
230
|
+
SUBMIT: [
|
|
231
|
+
{ when: (ctx) => !ctx.canSubmit, do: [] },
|
|
232
|
+
{ do: 'submit' },
|
|
233
|
+
],
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Access from hook
|
|
237
|
+
const { computed } = useMachine(machine, { input })
|
|
238
|
+
if (computed.isEmpty) { /* ... */ }
|
|
415
239
|
```
|
|
416
240
|
|
|
417
|
-
###
|
|
241
|
+
### Effects
|
|
418
242
|
|
|
419
|
-
|
|
243
|
+
Watch value changes and react:
|
|
420
244
|
|
|
421
245
|
```ts
|
|
422
246
|
effects: [
|
|
423
247
|
{
|
|
424
|
-
watch: (ctx) => ctx.
|
|
248
|
+
watch: (ctx) => ctx.highlightedId,
|
|
249
|
+
enter: (ctx, { send }) => {
|
|
250
|
+
// When watch becomes truthy
|
|
251
|
+
const timer = setTimeout(() => send('AUTO_SELECT'), 1000)
|
|
252
|
+
return () => clearTimeout(timer) // Cleanup
|
|
253
|
+
},
|
|
254
|
+
exit: () => {
|
|
255
|
+
// When watch becomes falsy
|
|
256
|
+
},
|
|
425
257
|
change: (ctx, prev, curr, { send }) => {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
fetch(`/api/search?q=${curr}`, { signal: controller.signal })
|
|
429
|
-
.then(res => res.json())
|
|
430
|
-
.then(data => send('FETCH_SUCCESS', { data }))
|
|
431
|
-
.catch(() => {})
|
|
432
|
-
|
|
433
|
-
return () => controller.abort() // Cancel previous request
|
|
258
|
+
// On any change
|
|
259
|
+
console.log(`${prev} → ${curr}`)
|
|
434
260
|
},
|
|
435
261
|
},
|
|
436
262
|
]
|
|
437
263
|
```
|
|
438
264
|
|
|
439
|
-
###
|
|
440
|
-
|
|
441
|
-
Use the `effect` helper for better type inference:
|
|
442
|
-
|
|
443
|
-
```ts
|
|
444
|
-
import { effect } from 'controlled-machine'
|
|
445
|
-
|
|
446
|
-
effects: [
|
|
447
|
-
effect<Context, Events, string | null>({
|
|
448
|
-
watch: (ctx) => ctx.focusedId,
|
|
449
|
-
enter: (ctx, { send }) => { /* ... */ },
|
|
450
|
-
}),
|
|
451
|
-
]
|
|
452
|
-
```
|
|
453
|
-
|
|
454
|
-
---
|
|
455
|
-
|
|
456
|
-
## Computed Values
|
|
265
|
+
### State-based Handlers
|
|
457
266
|
|
|
458
|
-
|
|
267
|
+
Different handlers per state:
|
|
459
268
|
|
|
460
269
|
```ts
|
|
461
270
|
const machine = createMachine<{
|
|
462
|
-
input:
|
|
463
|
-
events:
|
|
464
|
-
|
|
271
|
+
input: { state: 'idle' | 'loading' | 'error'; setState: (s) => void }
|
|
272
|
+
events: { FETCH: undefined; RETRY: undefined }
|
|
273
|
+
state: 'idle' | 'loading' | 'error'
|
|
465
274
|
}>({
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
275
|
+
states: {
|
|
276
|
+
idle: {
|
|
277
|
+
on: { FETCH: 'startFetch' },
|
|
278
|
+
},
|
|
279
|
+
loading: {
|
|
280
|
+
// FETCH ignored while loading
|
|
281
|
+
},
|
|
282
|
+
error: {
|
|
283
|
+
on: { RETRY: 'startFetch' },
|
|
284
|
+
},
|
|
475
285
|
},
|
|
286
|
+
actions: { startFetch: (ctx) => ctx.setState('loading') },
|
|
476
287
|
})
|
|
477
|
-
|
|
478
|
-
// Access computed values
|
|
479
|
-
const { computed } = useMachine(machine, input)
|
|
480
|
-
if (computed.isEmpty) { /* ... */ }
|
|
481
288
|
```
|
|
482
289
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
## Always Rules
|
|
290
|
+
### Always Rules
|
|
486
291
|
|
|
487
|
-
|
|
292
|
+
Auto-evaluated on every context change:
|
|
488
293
|
|
|
489
294
|
```ts
|
|
490
295
|
always: [
|
|
491
|
-
{ when: (ctx) => ctx.value < 0, do: '
|
|
492
|
-
{ when: (ctx) => ctx.value > 100, do: '
|
|
296
|
+
{ when: (ctx) => ctx.value < 0, do: 'clampToMin' },
|
|
297
|
+
{ when: (ctx) => ctx.value > 100, do: 'clampToMax' },
|
|
493
298
|
]
|
|
494
299
|
```
|
|
495
300
|
|
|
496
301
|
---
|
|
497
302
|
|
|
498
|
-
## Vanilla
|
|
303
|
+
## Vanilla Usage
|
|
499
304
|
|
|
500
305
|
Use without React:
|
|
501
306
|
|
|
502
307
|
```ts
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
const machine = createMachine<{ /* types */ }>({
|
|
506
|
-
on: { /* handlers */ },
|
|
507
|
-
actions: { /* actions */ },
|
|
508
|
-
})
|
|
308
|
+
const machine = createMachine({ /* config */ })
|
|
509
309
|
|
|
510
|
-
// Send events
|
|
511
|
-
machine.send('OPEN', { isOpen: false,
|
|
310
|
+
// Send events
|
|
311
|
+
machine.send('OPEN', { isOpen: false, onOpenChange: (v) => { /* ... */ } })
|
|
512
312
|
|
|
513
313
|
// Evaluate effects
|
|
514
314
|
machine.evaluate(input)
|
|
@@ -516,7 +316,7 @@ machine.evaluate(input)
|
|
|
516
316
|
// Get computed values
|
|
517
317
|
const computed = machine.getComputed(input)
|
|
518
318
|
|
|
519
|
-
// Cleanup
|
|
319
|
+
// Cleanup
|
|
520
320
|
machine.cleanup()
|
|
521
321
|
```
|
|
522
322
|
|
|
@@ -524,16 +324,16 @@ machine.cleanup()
|
|
|
524
324
|
|
|
525
325
|
## TypeScript
|
|
526
326
|
|
|
527
|
-
###
|
|
327
|
+
### Type Parameters
|
|
528
328
|
|
|
529
|
-
Specify only
|
|
329
|
+
Specify only what you need:
|
|
530
330
|
|
|
531
331
|
```ts
|
|
532
332
|
// Minimal
|
|
533
333
|
createMachine<{
|
|
534
334
|
input: MyInput
|
|
535
335
|
events: MyEvents
|
|
536
|
-
}>({
|
|
336
|
+
}>({ ... })
|
|
537
337
|
|
|
538
338
|
// Full
|
|
539
339
|
createMachine<{
|
|
@@ -541,32 +341,21 @@ createMachine<{
|
|
|
541
341
|
events: MyEvents
|
|
542
342
|
computed: MyComputed
|
|
543
343
|
actions: 'action1' | 'action2'
|
|
544
|
-
|
|
545
|
-
|
|
344
|
+
guards: 'guard1' | 'guard2'
|
|
345
|
+
state: 'idle' | 'active'
|
|
346
|
+
}>({ ... })
|
|
546
347
|
```
|
|
547
348
|
|
|
548
|
-
###
|
|
349
|
+
### Exports
|
|
549
350
|
|
|
550
351
|
```ts
|
|
551
|
-
import
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
Send,
|
|
555
|
-
Effect,
|
|
556
|
-
Rule,
|
|
557
|
-
Handler,
|
|
558
|
-
} from 'controlled-machine'
|
|
352
|
+
import { createMachine, effect } from 'controlled-machine'
|
|
353
|
+
import { useMachine } from 'controlled-machine/react'
|
|
354
|
+
import type { Machine, Send, Rule, Handler, UseMachineOptions } from 'controlled-machine'
|
|
559
355
|
```
|
|
560
356
|
|
|
561
357
|
---
|
|
562
358
|
|
|
563
|
-
## Limitations
|
|
564
|
-
|
|
565
|
-
- **No machine-to-machine communication** — Coordinate in parent components
|
|
566
|
-
- **No parallel states** — Use separate state variables
|
|
567
|
-
|
|
568
|
-
---
|
|
569
|
-
|
|
570
359
|
## License
|
|
571
360
|
|
|
572
361
|
MIT
|