controlled-machine 0.3.1 → 0.4.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 +558 -201
- package/dist/index.cjs +115 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +216 -43
- package/dist/index.d.ts +216 -43
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +115 -37
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +61 -36
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +31 -8
- package/dist/react.d.ts +31 -8
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +63 -38
- package/dist/react.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,301 +1,617 @@
|
|
|
1
1
|
# Controlled Machine
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Manage your React UI state cleanly.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- Separate logic from UI
|
|
6
|
+
- Declarative state transitions
|
|
7
|
+
- Full TypeScript support
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
```bash
|
|
10
|
+
npm install controlled-machine
|
|
11
|
+
```
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
---
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
## Why?
|
|
16
|
+
|
|
17
|
+
**Before (useState spaghetti):**
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
function Select() {
|
|
21
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
22
|
+
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
23
|
+
|
|
24
|
+
const handleKeyDown = (e) => {
|
|
25
|
+
if (e.key === 'ArrowDown') {
|
|
26
|
+
if (!isOpen) setIsOpen(true)
|
|
27
|
+
else setHighlightedIndex(i => Math.min(i + 1, items.length - 1))
|
|
28
|
+
}
|
|
29
|
+
if (e.key === 'Escape') {
|
|
30
|
+
setIsOpen(false)
|
|
31
|
+
setHighlightedIndex(-1)
|
|
32
|
+
}
|
|
33
|
+
// State dependencies get messy...
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**After (controlled-machine):**
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
function Select() {
|
|
42
|
+
const [snapshot, send] = useMachine(selectMachine, { input: { items } })
|
|
43
|
+
|
|
44
|
+
const handleKeyDown = (e) => {
|
|
45
|
+
if (e.key === 'ArrowDown') send('HIGHLIGHT_NEXT')
|
|
46
|
+
if (e.key === 'Escape') send('CLOSE')
|
|
47
|
+
}
|
|
48
|
+
// State logic is encapsulated in the machine
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Quick Start
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
import { createMachine } from 'controlled-machine'
|
|
58
|
+
import { useMachine } from 'controlled-machine/react'
|
|
59
|
+
|
|
60
|
+
const toggleMachine = createMachine<{
|
|
61
|
+
internal: { isOpen: boolean }
|
|
62
|
+
events: { TOGGLE: undefined }
|
|
18
63
|
}>({
|
|
64
|
+
internal: { isOpen: false },
|
|
19
65
|
on: {
|
|
20
|
-
|
|
21
|
-
CLOSE: [{ when: 'isOpen', do: ['close', 'focusTrigger'] }],
|
|
22
|
-
TOGGLE: [{ when: 'isOpen', do: 'close' }, { do: 'open' }],
|
|
23
|
-
},
|
|
24
|
-
actions: {
|
|
25
|
-
open: (ctx) => ctx.onOpenChange(true),
|
|
26
|
-
close: (ctx) => ctx.onOpenChange(false),
|
|
27
|
-
focusTrigger: () => {}, // Default: noop
|
|
28
|
-
},
|
|
29
|
-
guards: {
|
|
30
|
-
isOpen: (ctx) => ctx.isOpen,
|
|
66
|
+
TOGGLE: (ctx, _, assign) => assign({ isOpen: !ctx.isOpen }),
|
|
31
67
|
},
|
|
32
68
|
})
|
|
69
|
+
|
|
70
|
+
function Dropdown() {
|
|
71
|
+
const [snapshot, send] = useMachine(toggleMachine)
|
|
72
|
+
return (
|
|
73
|
+
<button onClick={() => send('TOGGLE')}>
|
|
74
|
+
{snapshot.isOpen ? 'Close' : 'Open'}
|
|
75
|
+
</button>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
33
78
|
```
|
|
34
79
|
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Full Example
|
|
83
|
+
|
|
84
|
+
A counter demonstrating most features. Copy and paste to try it out.
|
|
85
|
+
|
|
35
86
|
```tsx
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
87
|
+
import { useState } from 'react'
|
|
88
|
+
import { createMachine } from 'controlled-machine'
|
|
89
|
+
import { useMachine } from 'controlled-machine/react'
|
|
40
90
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
91
|
+
const counterMachine = createMachine<{
|
|
92
|
+
input: {
|
|
93
|
+
max: number
|
|
94
|
+
onChange?: (count: number) => void
|
|
95
|
+
}
|
|
96
|
+
internal: {
|
|
97
|
+
count: number
|
|
98
|
+
mode: 'normal' | 'turbo'
|
|
99
|
+
}
|
|
100
|
+
events: {
|
|
101
|
+
INCREMENT: undefined
|
|
102
|
+
DECREMENT: undefined
|
|
103
|
+
RESET: undefined
|
|
104
|
+
SET: { value: number }
|
|
105
|
+
TOGGLE_MODE: undefined
|
|
106
|
+
}
|
|
107
|
+
computed: {
|
|
108
|
+
doubled: number
|
|
109
|
+
isAtMax: boolean
|
|
110
|
+
step: number
|
|
111
|
+
}
|
|
112
|
+
guards: 'canIncrement' | 'canDecrement'
|
|
113
|
+
actions: 'notifyChange'
|
|
114
|
+
}>({
|
|
115
|
+
internal: {
|
|
116
|
+
count: 0,
|
|
117
|
+
mode: 'normal',
|
|
118
|
+
},
|
|
119
|
+
computed: {
|
|
120
|
+
doubled: (ctx) => ctx.count * 2,
|
|
121
|
+
isAtMax: (ctx) => ctx.count >= ctx.max,
|
|
122
|
+
step: (ctx) => (ctx.mode === 'turbo' ? 10 : 1),
|
|
123
|
+
},
|
|
124
|
+
guards: {
|
|
125
|
+
canIncrement: (ctx) => ctx.count < ctx.max,
|
|
126
|
+
canDecrement: (ctx) => ctx.count > 0,
|
|
127
|
+
},
|
|
128
|
+
actions: {
|
|
129
|
+
notifyChange: (ctx) => ctx.onChange?.(ctx.count),
|
|
130
|
+
},
|
|
131
|
+
on: {
|
|
132
|
+
INCREMENT: [
|
|
133
|
+
{ when: 'canIncrement', do: (ctx, _, assign) => assign({ count: ctx.count + ctx.step }) },
|
|
134
|
+
],
|
|
135
|
+
DECREMENT: [
|
|
136
|
+
{ when: 'canDecrement', do: (ctx, _, assign) => assign({ count: ctx.count - ctx.step }) },
|
|
137
|
+
],
|
|
138
|
+
RESET: [{ do: [(_, __, assign) => assign({ count: 0 }), 'notifyChange'] }],
|
|
139
|
+
SET: (_, { value }, assign) => assign({ count: value }),
|
|
140
|
+
TOGGLE_MODE: (ctx, _, assign) =>
|
|
141
|
+
assign({ mode: ctx.mode === 'normal' ? 'turbo' : 'normal' }),
|
|
142
|
+
},
|
|
143
|
+
always: [
|
|
144
|
+
{ when: (ctx) => ctx.count > ctx.max, do: (ctx, __, assign) => assign({ count: ctx.max }) },
|
|
145
|
+
{ when: (ctx) => ctx.count < 0, do: (_, __, assign) => assign({ count: 0 }) },
|
|
146
|
+
],
|
|
147
|
+
effects: [
|
|
148
|
+
{
|
|
149
|
+
watch: (ctx) => ctx.count,
|
|
150
|
+
change: (ctx, _prev, _curr) => ctx.onChange?.(ctx.count),
|
|
46
151
|
},
|
|
152
|
+
{
|
|
153
|
+
watch: (ctx) => ctx.isAtMax,
|
|
154
|
+
enter: () => console.log('Max reached!'),
|
|
155
|
+
exit: () => console.log('Left max'),
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
function Counter({ max, onChange }: { max: number; onChange?: (n: number) => void }) {
|
|
161
|
+
const [snapshot, send] = useMachine(counterMachine, {
|
|
162
|
+
input: { max, onChange },
|
|
47
163
|
})
|
|
48
164
|
|
|
49
165
|
return (
|
|
50
|
-
|
|
51
|
-
<
|
|
52
|
-
|
|
166
|
+
<div style={{ fontFamily: 'system-ui', padding: 20 }}>
|
|
167
|
+
<div style={{ fontSize: 48, marginBottom: 16 }}>
|
|
168
|
+
{snapshot.count}
|
|
169
|
+
<span style={{ fontSize: 16, color: '#888' }}> (x2 = {snapshot.doubled})</span>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
|
173
|
+
<button onClick={() => send('DECREMENT')} disabled={snapshot.count <= 0}>
|
|
174
|
+
-{snapshot.step}
|
|
175
|
+
</button>
|
|
176
|
+
<button onClick={() => send('INCREMENT')} disabled={snapshot.isAtMax}>
|
|
177
|
+
+{snapshot.step}
|
|
178
|
+
</button>
|
|
179
|
+
<button onClick={() => send('RESET')}>Reset</button>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<button onClick={() => send('TOGGLE_MODE')} style={{ marginBottom: 16 }}>
|
|
183
|
+
Mode: {snapshot.mode}
|
|
53
184
|
</button>
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
185
|
+
|
|
186
|
+
<input
|
|
187
|
+
type="range"
|
|
188
|
+
min={0}
|
|
189
|
+
max={max}
|
|
190
|
+
value={snapshot.count}
|
|
191
|
+
onChange={(e) => send('SET', { value: Number(e.target.value) })}
|
|
192
|
+
style={{ width: '100%' }}
|
|
193
|
+
/>
|
|
194
|
+
|
|
195
|
+
{snapshot.isAtMax && <div style={{ color: 'green', marginTop: 8 }}>Maximum!</div>}
|
|
196
|
+
</div>
|
|
60
197
|
)
|
|
61
198
|
}
|
|
62
|
-
```
|
|
63
199
|
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
200
|
+
export default function App() {
|
|
201
|
+
const [lastChange, setLastChange] = useState<number | null>(null)
|
|
69
202
|
|
|
70
|
-
|
|
203
|
+
return (
|
|
204
|
+
<div style={{ padding: 40 }}>
|
|
205
|
+
<h1>Counter Machine</h1>
|
|
206
|
+
<Counter max={100} onChange={setLastChange} />
|
|
207
|
+
{lastChange !== null && <p>Last change: {lastChange}</p>}
|
|
208
|
+
</div>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
```
|
|
71
212
|
|
|
72
|
-
|
|
213
|
+
**Features demonstrated:**
|
|
73
214
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
215
|
+
| Feature | Description |
|
|
216
|
+
|---------|-------------|
|
|
217
|
+
| `input` | External values (`max`, `onChange`) |
|
|
218
|
+
| `internal` | Machine-managed state (`count`, `mode`) |
|
|
219
|
+
| `computed` | Derived values (`doubled`, `isAtMax`, `step`) |
|
|
220
|
+
| `guards` | Condition checks (`canIncrement`, `canDecrement`) |
|
|
221
|
+
| `actions` | Named actions (`notifyChange`) |
|
|
222
|
+
| `always` | Auto state correction (0~max clamping) |
|
|
223
|
+
| `effects` | Side effects (onChange callback, logging) |
|
|
77
224
|
|
|
78
225
|
---
|
|
79
226
|
|
|
80
227
|
## Core Concepts
|
|
81
228
|
|
|
82
|
-
###
|
|
229
|
+
### Internal vs Input
|
|
83
230
|
|
|
84
|
-
|
|
231
|
+
```
|
|
232
|
+
┌─────────────────────────────────────────┐
|
|
233
|
+
│ Component (React) │
|
|
234
|
+
│ ┌───────────────┐ ┌───────────────┐ │
|
|
235
|
+
│ │ Input │ │ Snapshot │ │
|
|
236
|
+
│ │ (pass in) │ │ (read from) │ │
|
|
237
|
+
│ └───────┬───────┘ └───────▲───────┘ │
|
|
238
|
+
│ │ │ │
|
|
239
|
+
│ ▼ │ │
|
|
240
|
+
│ ┌───────────────────────────┴───────┐ │
|
|
241
|
+
│ │ Machine │ │
|
|
242
|
+
│ │ ┌─────────┐ ┌─────────────────┐ │ │
|
|
243
|
+
│ │ │ Internal│ │ Computed │ │ │
|
|
244
|
+
│ │ │ (state) │ │ (derived values)│ │ │
|
|
245
|
+
│ │ └─────────┘ └─────────────────┘ │ │
|
|
246
|
+
│ └───────────────────────────────────┘ │
|
|
247
|
+
└─────────────────────────────────────────┘
|
|
248
|
+
```
|
|
85
249
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
250
|
+
**Input** - Values passed from outside:
|
|
251
|
+
- Values controlled by parent component (props)
|
|
252
|
+
- Callback functions (`onChange`, `onSelect`)
|
|
253
|
+
- External data (`items`, `options`)
|
|
90
254
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
255
|
+
**Internal** - State managed by the machine:
|
|
256
|
+
- UI-only state (`isOpen`, `highlightedIndex`)
|
|
257
|
+
- Values initialized on component mount
|
|
258
|
+
- State that doesn't need external control
|
|
259
|
+
|
|
260
|
+
```tsx
|
|
261
|
+
// Input: controlled by parent
|
|
262
|
+
const [value, setValue] = useState('')
|
|
263
|
+
<Combobox value={value} onChange={setValue} items={items} />
|
|
264
|
+
|
|
265
|
+
// Internal: controlled by machine (isOpen, highlightedIndex, etc.)
|
|
266
|
+
const comboboxMachine = createMachine<{
|
|
267
|
+
input: { value: string; onChange: (v: string) => void; items: Item[] }
|
|
268
|
+
internal: { isOpen: boolean; highlightedIndex: number }
|
|
269
|
+
// ...
|
|
270
|
+
}>({ ... })
|
|
95
271
|
```
|
|
96
272
|
|
|
97
|
-
###
|
|
273
|
+
### Context
|
|
98
274
|
|
|
99
|
-
|
|
275
|
+
Inside handlers, `input + internal + computed` are merged into one flat object:
|
|
100
276
|
|
|
101
277
|
```ts
|
|
102
278
|
on: {
|
|
103
|
-
SELECT:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
279
|
+
SELECT: (ctx, payload, assign) => {
|
|
280
|
+
// ctx.value ← input
|
|
281
|
+
// ctx.isOpen ← internal
|
|
282
|
+
// ctx.selectedItem ← computed
|
|
283
|
+
// All accessible at the same level
|
|
284
|
+
}
|
|
108
285
|
}
|
|
109
286
|
```
|
|
110
287
|
|
|
111
|
-
###
|
|
288
|
+
### Snapshot
|
|
112
289
|
|
|
113
|
-
|
|
290
|
+
The value returned by `useMachine`. Contains **Internal + Computed** (excludes Input):
|
|
114
291
|
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
const machine = createMachine({
|
|
118
|
-
actions: {
|
|
119
|
-
scrollToItem: () => {}, // noop default
|
|
120
|
-
focusInput: () => {},
|
|
121
|
-
},
|
|
122
|
-
guards: {
|
|
123
|
-
canSelect: (ctx) => !ctx.disabled,
|
|
124
|
-
},
|
|
125
|
-
})
|
|
292
|
+
```tsx
|
|
293
|
+
const [snapshot, send] = useMachine(machine, { input: { value, items } })
|
|
126
294
|
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
})
|
|
295
|
+
snapshot.isOpen // ← internal
|
|
296
|
+
snapshot.selectedItem // ← computed
|
|
297
|
+
// snapshot.value // ❌ input is not in snapshot
|
|
138
298
|
```
|
|
139
299
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
## API Reference
|
|
300
|
+
### Assign
|
|
143
301
|
|
|
144
|
-
|
|
302
|
+
Function to update internal state. The third argument in handlers:
|
|
145
303
|
|
|
146
304
|
```ts
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
computed: { isPositive: boolean }
|
|
151
|
-
actions: 'increment' | 'set'
|
|
152
|
-
guards: 'canIncrement'
|
|
153
|
-
state: 'idle' | 'active' // Optional: for state-based handlers
|
|
154
|
-
}>({
|
|
155
|
-
computed: {
|
|
156
|
-
isPositive: (input) => input.count > 0,
|
|
157
|
-
},
|
|
158
|
-
on: {
|
|
159
|
-
INCREMENT: [{ when: 'canIncrement', do: 'increment' }],
|
|
160
|
-
SET: 'set',
|
|
161
|
-
},
|
|
162
|
-
actions: {
|
|
163
|
-
increment: (ctx) => ctx.setCount(ctx.count + 1),
|
|
164
|
-
set: (ctx, payload) => ctx.setCount(payload.value),
|
|
305
|
+
on: {
|
|
306
|
+
OPEN: (ctx, payload, assign) => {
|
|
307
|
+
assign({ isOpen: true }) // Partial update
|
|
165
308
|
},
|
|
166
|
-
|
|
167
|
-
|
|
309
|
+
RESET: (_, __, assign) => {
|
|
310
|
+
assign({ isOpen: false, highlightedIndex: -1 }) // Multiple keys
|
|
168
311
|
},
|
|
169
|
-
}
|
|
312
|
+
}
|
|
170
313
|
```
|
|
171
314
|
|
|
172
|
-
|
|
315
|
+
---
|
|
173
316
|
|
|
174
|
-
|
|
175
|
-
const { send, computed, state } = useMachine(machine, {
|
|
176
|
-
input: { count, setCount },
|
|
177
|
-
actions: { /* optional overrides */ },
|
|
178
|
-
guards: { /* optional overrides */ },
|
|
179
|
-
})
|
|
317
|
+
## Event Handlers
|
|
180
318
|
|
|
181
|
-
|
|
182
|
-
send('SET', { value: 5 })
|
|
183
|
-
```
|
|
319
|
+
### Handler Patterns
|
|
184
320
|
|
|
185
|
-
|
|
321
|
+
| Pattern | Form | When to use |
|
|
322
|
+
|---------|------|-------------|
|
|
323
|
+
| Inline function | `(ctx, payload, assign) => ...` | Simple state changes |
|
|
324
|
+
| Function array | `[fn1, fn2, fn3]` | Sequential operations |
|
|
325
|
+
| String | `'actionName'` | Named action call |
|
|
326
|
+
| String array | `['action1', 'action2']` | Sequential named actions |
|
|
327
|
+
| Rule array | `[{ when, do }, { do }]` | Conditional branching |
|
|
186
328
|
|
|
187
|
-
|
|
329
|
+
### Inline Function
|
|
188
330
|
|
|
189
|
-
|
|
331
|
+
```ts
|
|
332
|
+
on: {
|
|
333
|
+
TOGGLE: (ctx, _, assign) => assign({ isOpen: !ctx.isOpen }),
|
|
334
|
+
SET_VALUE: (ctx, { value }, assign) => assign({ value }),
|
|
335
|
+
}
|
|
336
|
+
```
|
|
190
337
|
|
|
191
|
-
|
|
338
|
+
**Handler parameters:**
|
|
339
|
+
- `ctx` - Context (input + internal + computed)
|
|
340
|
+
- `payload` - Value passed via `send('EVENT', payload)`
|
|
341
|
+
- `assign` - Internal state update function
|
|
342
|
+
|
|
343
|
+
### Function Array
|
|
344
|
+
|
|
345
|
+
Execute multiple operations sequentially. Each function receives fresh context after previous `assign`:
|
|
192
346
|
|
|
193
347
|
```ts
|
|
194
348
|
on: {
|
|
195
|
-
|
|
196
|
-
{
|
|
197
|
-
{
|
|
198
|
-
{ do: 'open' }, // Default case
|
|
349
|
+
SELECT: [
|
|
350
|
+
(ctx, { value }) => ctx.onChange(value), // 1. Call callback
|
|
351
|
+
(_, __, assign) => assign({ isOpen: false }), // 2. Close
|
|
199
352
|
],
|
|
200
353
|
}
|
|
201
354
|
```
|
|
202
355
|
|
|
203
|
-
###
|
|
356
|
+
### Rule Array (Conditional Branching)
|
|
204
357
|
|
|
205
|
-
|
|
358
|
+
Only the first rule with matching `when` condition executes (first match wins):
|
|
206
359
|
|
|
207
360
|
```ts
|
|
208
361
|
on: {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
{ when: 'isValid', do: ['save', 'close', 'notify'] },
|
|
213
|
-
{ do: 'showError' },
|
|
362
|
+
TOGGLE: [
|
|
363
|
+
{ when: 'isOpen', do: (_, __, assign) => assign({ isOpen: false }) },
|
|
364
|
+
{ do: (_, __, assign) => assign({ isOpen: true }) }, // default
|
|
214
365
|
],
|
|
215
366
|
}
|
|
216
367
|
```
|
|
217
368
|
|
|
218
|
-
###
|
|
369
|
+
### Named Actions
|
|
219
370
|
|
|
220
|
-
|
|
371
|
+
Define reusable actions:
|
|
221
372
|
|
|
222
373
|
```ts
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
374
|
+
createMachine<{
|
|
375
|
+
// ...
|
|
376
|
+
actions: 'logValue' | 'notifyChange'
|
|
377
|
+
}>({
|
|
378
|
+
actions: {
|
|
379
|
+
logValue: (ctx) => console.log(ctx.value),
|
|
380
|
+
notifyChange: (ctx) => ctx.onChange?.(ctx.value),
|
|
381
|
+
},
|
|
382
|
+
on: {
|
|
383
|
+
CHANGE: ['logValue', 'notifyChange'], // Call by string
|
|
384
|
+
},
|
|
385
|
+
})
|
|
386
|
+
```
|
|
227
387
|
|
|
228
|
-
|
|
388
|
+
### Mixing Functions and Strings
|
|
389
|
+
|
|
390
|
+
**Only possible inside Rule's `do`:**
|
|
391
|
+
|
|
392
|
+
```ts
|
|
229
393
|
on: {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
394
|
+
// ❌ Doesn't work - mixing at handler level
|
|
395
|
+
EVENT: [(ctx) => console.log(ctx), 'actionName'],
|
|
396
|
+
|
|
397
|
+
// ✅ Works - mixing inside Rule's do
|
|
398
|
+
EVENT: [{ do: [(ctx) => console.log(ctx), 'actionName'] }],
|
|
234
399
|
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
404
|
+
## Guards
|
|
405
|
+
|
|
406
|
+
Guard functions for conditional execution:
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
createMachine<{
|
|
410
|
+
// ...
|
|
411
|
+
guards: 'canIncrement' | 'canDecrement'
|
|
412
|
+
}>({
|
|
413
|
+
guards: {
|
|
414
|
+
canIncrement: (ctx) => ctx.count < ctx.max,
|
|
415
|
+
canDecrement: (ctx) => ctx.count > 0,
|
|
416
|
+
},
|
|
417
|
+
on: {
|
|
418
|
+
INCREMENT: [
|
|
419
|
+
{ when: 'canIncrement', do: (ctx, _, assign) => assign({ count: ctx.count + 1 }) },
|
|
420
|
+
],
|
|
421
|
+
},
|
|
422
|
+
})
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
**Guard usage:**
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
// 1. Named guard (string)
|
|
429
|
+
{ when: 'canIncrement', do: ... }
|
|
430
|
+
|
|
431
|
+
// 2. Inline guard (function)
|
|
432
|
+
{ when: (ctx) => ctx.count < 10, do: ... }
|
|
433
|
+
|
|
434
|
+
// 3. Multiple guards (AND condition)
|
|
435
|
+
{ when: ['isEnabled', 'canIncrement', (ctx) => !ctx.isLoading], do: ... }
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
## Computed Values
|
|
441
|
+
|
|
442
|
+
Values derived from Input and Internal:
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
createMachine<{
|
|
446
|
+
input: { items: Item[] }
|
|
447
|
+
internal: { selectedIndex: number }
|
|
448
|
+
computed: { selectedItem: Item | null }
|
|
449
|
+
}>({
|
|
450
|
+
computed: {
|
|
451
|
+
selectedItem: (ctx) => ctx.items[ctx.selectedIndex] ?? null,
|
|
452
|
+
},
|
|
453
|
+
})
|
|
235
454
|
|
|
236
|
-
//
|
|
237
|
-
const
|
|
238
|
-
|
|
455
|
+
// Usage
|
|
456
|
+
const [snapshot] = useMachine(machine, { input: { items } })
|
|
457
|
+
console.log(snapshot.selectedItem) // Access computed value
|
|
239
458
|
```
|
|
240
459
|
|
|
241
|
-
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
## Effects
|
|
242
463
|
|
|
243
|
-
|
|
464
|
+
Detect value changes and execute side effects:
|
|
244
465
|
|
|
245
466
|
```ts
|
|
246
467
|
effects: [
|
|
247
468
|
{
|
|
248
|
-
watch: (ctx) => ctx.
|
|
469
|
+
watch: (ctx) => ctx.searchQuery, // Value to watch (shallow compare)
|
|
470
|
+
|
|
249
471
|
enter: (ctx, { send }) => {
|
|
250
|
-
// When watch becomes truthy
|
|
251
|
-
const timer = setTimeout(() => send('
|
|
252
|
-
return () => clearTimeout(timer) //
|
|
472
|
+
// When watch value becomes falsy → truthy
|
|
473
|
+
const timer = setTimeout(() => send('SEARCH'), 300)
|
|
474
|
+
return () => clearTimeout(timer) // Can return cleanup function
|
|
253
475
|
},
|
|
254
|
-
|
|
255
|
-
|
|
476
|
+
|
|
477
|
+
exit: (ctx, { send }) => {
|
|
478
|
+
// When watch value becomes truthy → falsy
|
|
479
|
+
send('CLEAR_RESULTS')
|
|
256
480
|
},
|
|
481
|
+
|
|
257
482
|
change: (ctx, prev, curr, { send }) => {
|
|
258
|
-
//
|
|
483
|
+
// When watch value changes
|
|
259
484
|
console.log(`${prev} → ${curr}`)
|
|
260
485
|
},
|
|
261
486
|
},
|
|
262
487
|
]
|
|
263
488
|
```
|
|
264
489
|
|
|
265
|
-
|
|
490
|
+
**Trigger conditions:**
|
|
491
|
+
| Callback | When it runs |
|
|
492
|
+
|----------|--------------|
|
|
493
|
+
| `enter` | `watch` returns falsy → truthy |
|
|
494
|
+
| `exit` | `watch` returns truthy → falsy |
|
|
495
|
+
| `change` | `watch` return value changes |
|
|
496
|
+
|
|
497
|
+
---
|
|
498
|
+
|
|
499
|
+
## Always Rules
|
|
266
500
|
|
|
267
|
-
|
|
501
|
+
Rules automatically evaluated whenever context changes:
|
|
268
502
|
|
|
269
503
|
```ts
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
504
|
+
always: [
|
|
505
|
+
{
|
|
506
|
+
when: (ctx) => ctx.count < 0,
|
|
507
|
+
do: (_, __, assign) => assign({ count: 0 }),
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
when: (ctx) => ctx.count > ctx.max,
|
|
511
|
+
do: (ctx, __, assign) => assign({ count: ctx.max }),
|
|
512
|
+
},
|
|
513
|
+
]
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
### Always vs Effects
|
|
517
|
+
|
|
518
|
+
| | Always | Effects |
|
|
519
|
+
|---|--------|---------|
|
|
520
|
+
| **Purpose** | State correction/constraints | Side effects |
|
|
521
|
+
| **When** | Synchronous during render | Inside useEffect |
|
|
522
|
+
| **Use for** | Value clamping, validation | API calls, timers, logging |
|
|
523
|
+
| **Cleanup** | None | Can return cleanup |
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
## State-based Handlers (FSM)
|
|
528
|
+
|
|
529
|
+
Execute different handlers based on state:
|
|
530
|
+
|
|
531
|
+
```ts
|
|
532
|
+
const fetchMachine = createMachine<{
|
|
533
|
+
internal: { state: 'idle' | 'loading' | 'success' | 'error'; data: any }
|
|
534
|
+
events: { FETCH: undefined; SUCCESS: { data: any }; ERROR: undefined; RETRY: undefined }
|
|
535
|
+
state: 'idle' | 'loading' | 'success' | 'error'
|
|
274
536
|
}>({
|
|
537
|
+
internal: { state: 'idle', data: null },
|
|
538
|
+
|
|
275
539
|
states: {
|
|
276
540
|
idle: {
|
|
277
|
-
on: {
|
|
541
|
+
on: {
|
|
542
|
+
FETCH: (_, __, assign) => assign({ state: 'loading' }),
|
|
543
|
+
},
|
|
278
544
|
},
|
|
279
545
|
loading: {
|
|
280
|
-
|
|
546
|
+
on: {
|
|
547
|
+
SUCCESS: (_, { data }, assign) => assign({ state: 'success', data }),
|
|
548
|
+
ERROR: (_, __, assign) => assign({ state: 'error' }),
|
|
549
|
+
// FETCH is ignored (no handler)
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
success: {
|
|
553
|
+
on: {
|
|
554
|
+
FETCH: (_, __, assign) => assign({ state: 'loading', data: null }),
|
|
555
|
+
},
|
|
281
556
|
},
|
|
282
557
|
error: {
|
|
283
|
-
on: {
|
|
558
|
+
on: {
|
|
559
|
+
RETRY: (_, __, assign) => assign({ state: 'loading' }),
|
|
560
|
+
},
|
|
284
561
|
},
|
|
285
562
|
},
|
|
286
|
-
actions: { startFetch: (ctx) => ctx.setState('loading') },
|
|
287
563
|
})
|
|
288
564
|
```
|
|
289
565
|
|
|
290
|
-
|
|
566
|
+
**Where `state` can live:**
|
|
567
|
+
- `internal` - Transition directly with `assign()`
|
|
568
|
+
- `computed` - Derived from other values
|
|
569
|
+
- `input` - Controlled by parent
|
|
570
|
+
|
|
571
|
+
---
|
|
572
|
+
|
|
573
|
+
## Action & Guard Overrides
|
|
291
574
|
|
|
292
|
-
|
|
575
|
+
Override actions/guards per component:
|
|
293
576
|
|
|
294
577
|
```ts
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
578
|
+
const [snapshot, send] = useMachine(machine, {
|
|
579
|
+
input: { value },
|
|
580
|
+
actions: {
|
|
581
|
+
logValue: (ctx) => {
|
|
582
|
+
// Different implementation for this component
|
|
583
|
+
analytics.track('value', ctx.value)
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
guards: {
|
|
587
|
+
canSubmit: (ctx) => ctx.value.length > 5, // Stricter condition
|
|
588
|
+
},
|
|
589
|
+
})
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
---
|
|
593
|
+
|
|
594
|
+
## Factory Pattern
|
|
595
|
+
|
|
596
|
+
Isolated machine instances per component:
|
|
597
|
+
|
|
598
|
+
```ts
|
|
599
|
+
const createCounterMachine = (initialCount: number) =>
|
|
600
|
+
createMachine<{
|
|
601
|
+
internal: { count: number }
|
|
602
|
+
events: { INCREMENT: undefined }
|
|
603
|
+
}>({
|
|
604
|
+
internal: { count: initialCount },
|
|
605
|
+
on: {
|
|
606
|
+
INCREMENT: (ctx, _, assign) => assign({ count: ctx.count + 1 }),
|
|
607
|
+
},
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
// Each component gets its own instance
|
|
611
|
+
function Counter() {
|
|
612
|
+
const [snapshot, send] = useMachine(() => createCounterMachine(100))
|
|
613
|
+
// ...
|
|
614
|
+
}
|
|
299
615
|
```
|
|
300
616
|
|
|
301
617
|
---
|
|
@@ -305,18 +621,36 @@ always: [
|
|
|
305
621
|
Use without React:
|
|
306
622
|
|
|
307
623
|
```ts
|
|
308
|
-
const machine = createMachine
|
|
624
|
+
const machine = createMachine<{
|
|
625
|
+
input: { multiplier: number }
|
|
626
|
+
internal: { count: number }
|
|
627
|
+
computed: { doubled: number }
|
|
628
|
+
events: { INCREMENT: undefined }
|
|
629
|
+
}>({
|
|
630
|
+
internal: { count: 0 },
|
|
631
|
+
computed: { doubled: (ctx) => ctx.count * ctx.multiplier },
|
|
632
|
+
on: {
|
|
633
|
+
INCREMENT: (ctx, _, assign) => assign({ count: ctx.count + 1 }),
|
|
634
|
+
},
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
// Send events (input required)
|
|
638
|
+
machine.send('INCREMENT', { multiplier: 2 })
|
|
309
639
|
|
|
310
|
-
//
|
|
311
|
-
machine.
|
|
640
|
+
// Get snapshot
|
|
641
|
+
const snapshot = machine.getSnapshot({ multiplier: 2 })
|
|
642
|
+
console.log(snapshot.count) // 1
|
|
643
|
+
console.log(snapshot.doubled) // 2
|
|
312
644
|
|
|
313
645
|
// Evaluate effects
|
|
314
|
-
machine.evaluate(
|
|
646
|
+
machine.evaluate({ multiplier: 2 })
|
|
315
647
|
|
|
316
|
-
//
|
|
317
|
-
|
|
648
|
+
// Internal state management
|
|
649
|
+
machine.getInternal() // { count: 1 }
|
|
650
|
+
machine.setInternal({ count: 0 }) // Reset
|
|
651
|
+
machine.getInitialInternal() // { count: 0 }
|
|
318
652
|
|
|
319
|
-
// Cleanup
|
|
653
|
+
// Cleanup effects
|
|
320
654
|
machine.cleanup()
|
|
321
655
|
```
|
|
322
656
|
|
|
@@ -326,24 +660,36 @@ machine.cleanup()
|
|
|
326
660
|
|
|
327
661
|
### Type Parameters
|
|
328
662
|
|
|
329
|
-
Specify only what you need:
|
|
330
|
-
|
|
331
663
|
```ts
|
|
332
|
-
// Minimal
|
|
333
664
|
createMachine<{
|
|
334
|
-
input:
|
|
335
|
-
|
|
665
|
+
input: { ... } // External data
|
|
666
|
+
internal: { ... } // Machine state
|
|
667
|
+
events: { ... } // Event → payload
|
|
668
|
+
computed: { ... } // Derived values
|
|
669
|
+
actions: string // Named action union
|
|
670
|
+
guards: string // Named guard union
|
|
671
|
+
state: string // FSM state union
|
|
336
672
|
}>({ ... })
|
|
673
|
+
```
|
|
337
674
|
|
|
338
|
-
|
|
675
|
+
**Events type:**
|
|
676
|
+
```ts
|
|
677
|
+
events: {
|
|
678
|
+
TOGGLE: undefined // No payload: send('TOGGLE')
|
|
679
|
+
SET: { value: string } // With payload: send('SET', { value: 'hello' })
|
|
680
|
+
}
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
### Key Safety
|
|
684
|
+
|
|
685
|
+
Duplicate keys across `input`, `internal`, `computed` cause compile error:
|
|
686
|
+
|
|
687
|
+
```ts
|
|
339
688
|
createMachine<{
|
|
340
|
-
input:
|
|
341
|
-
|
|
342
|
-
computed: MyComputed
|
|
343
|
-
actions: 'action1' | 'action2'
|
|
344
|
-
guards: 'guard1' | 'guard2'
|
|
345
|
-
state: 'idle' | 'active'
|
|
689
|
+
input: { count: number }
|
|
690
|
+
internal: { count: string } // ❌ 'count' key duplicated
|
|
346
691
|
}>({ ... })
|
|
692
|
+
// → Context type becomes 'never', causing error
|
|
347
693
|
```
|
|
348
694
|
|
|
349
695
|
### Exports
|
|
@@ -351,7 +697,18 @@ createMachine<{
|
|
|
351
697
|
```ts
|
|
352
698
|
import { createMachine, effect } from 'controlled-machine'
|
|
353
699
|
import { useMachine } from 'controlled-machine/react'
|
|
354
|
-
import type {
|
|
700
|
+
import type {
|
|
701
|
+
MachineTypes,
|
|
702
|
+
Machine,
|
|
703
|
+
MachineInstance,
|
|
704
|
+
Context,
|
|
705
|
+
Snapshot,
|
|
706
|
+
Send,
|
|
707
|
+
Input,
|
|
708
|
+
Internal,
|
|
709
|
+
Computed,
|
|
710
|
+
AssignFn,
|
|
711
|
+
} from 'controlled-machine'
|
|
355
712
|
```
|
|
356
713
|
|
|
357
714
|
---
|