controlled-machine 0.3.2 → 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 +542 -241
- package/dist/index.cjs +99 -42
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +213 -44
- package/dist/index.d.ts +213 -44
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +99 -42
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +60 -34
- 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 +62 -36
- package/dist/react.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,349 +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
|
+
```
|
|
12
|
+
|
|
13
|
+
---
|
|
8
14
|
|
|
9
|
-
|
|
15
|
+
## Why?
|
|
10
16
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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),
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
watch: (ctx) => ctx.isAtMax,
|
|
154
|
+
enter: () => console.log('Max reached!'),
|
|
155
|
+
exit: () => console.log('Left max'),
|
|
46
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 |
|
|
328
|
+
|
|
329
|
+
### Inline Function
|
|
186
330
|
|
|
187
|
-
|
|
331
|
+
```ts
|
|
332
|
+
on: {
|
|
333
|
+
TOGGLE: (ctx, _, assign) => assign({ isOpen: !ctx.isOpen }),
|
|
334
|
+
SET_VALUE: (ctx, { value }, assign) => assign({ value }),
|
|
335
|
+
}
|
|
336
|
+
```
|
|
188
337
|
|
|
189
|
-
|
|
338
|
+
**Handler parameters:**
|
|
339
|
+
- `ctx` - Context (input + internal + computed)
|
|
340
|
+
- `payload` - Value passed via `send('EVENT', payload)`
|
|
341
|
+
- `assign` - Internal state update function
|
|
190
342
|
|
|
191
|
-
|
|
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: open
|
|
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
362
|
TOGGLE: [
|
|
210
|
-
{ when: 'isOpen', do:
|
|
211
|
-
{
|
|
212
|
-
],
|
|
213
|
-
|
|
214
|
-
// Multiple guards - ALL must pass (AND logic)
|
|
215
|
-
DELETE: [
|
|
216
|
-
{
|
|
217
|
-
when: ['isAdmin', 'hasPermission', (ctx) => !ctx.isLocked],
|
|
218
|
-
do: 'deleteItem',
|
|
219
|
-
},
|
|
220
|
-
{ do: 'showError' },
|
|
363
|
+
{ when: 'isOpen', do: (_, __, assign) => assign({ isOpen: false }) },
|
|
364
|
+
{ do: (_, __, assign) => assign({ isOpen: true }) }, // default
|
|
221
365
|
],
|
|
222
366
|
}
|
|
223
367
|
```
|
|
224
368
|
|
|
225
|
-
###
|
|
369
|
+
### Named Actions
|
|
226
370
|
|
|
227
|
-
|
|
371
|
+
Define reusable actions:
|
|
228
372
|
|
|
229
373
|
```ts
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
'logSubmit', // Named action
|
|
243
|
-
(ctx, payload) => ctx.submit(payload), // Inline function
|
|
244
|
-
'showSuccess', // Named action
|
|
245
|
-
],
|
|
246
|
-
},
|
|
247
|
-
],
|
|
248
|
-
}
|
|
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
|
+
})
|
|
249
386
|
```
|
|
250
387
|
|
|
251
|
-
###
|
|
388
|
+
### Mixing Functions and Strings
|
|
252
389
|
|
|
253
|
-
|
|
390
|
+
**Only possible inside Rule's `do`:**
|
|
254
391
|
|
|
255
392
|
```ts
|
|
256
393
|
on: {
|
|
257
|
-
|
|
394
|
+
// ❌ Doesn't work - mixing at handler level
|
|
395
|
+
EVENT: [(ctx) => console.log(ctx), 'actionName'],
|
|
258
396
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
{ do: 'showError' },
|
|
262
|
-
],
|
|
397
|
+
// ✅ Works - mixing inside Rule's do
|
|
398
|
+
EVENT: [{ do: [(ctx) => console.log(ctx), 'actionName'] }],
|
|
263
399
|
}
|
|
264
400
|
```
|
|
265
401
|
|
|
266
|
-
|
|
402
|
+
---
|
|
403
|
+
|
|
404
|
+
## Guards
|
|
267
405
|
|
|
268
|
-
|
|
406
|
+
Guard functions for conditional execution:
|
|
269
407
|
|
|
270
408
|
```ts
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
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
|
+
```
|
|
275
424
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
283
441
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
+
})
|
|
454
|
+
|
|
455
|
+
// Usage
|
|
456
|
+
const [snapshot] = useMachine(machine, { input: { items } })
|
|
457
|
+
console.log(snapshot.selectedItem) // Access computed value
|
|
287
458
|
```
|
|
288
459
|
|
|
289
|
-
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
## Effects
|
|
290
463
|
|
|
291
|
-
|
|
464
|
+
Detect value changes and execute side effects:
|
|
292
465
|
|
|
293
466
|
```ts
|
|
294
467
|
effects: [
|
|
295
468
|
{
|
|
296
|
-
watch: (ctx) => ctx.
|
|
469
|
+
watch: (ctx) => ctx.searchQuery, // Value to watch (shallow compare)
|
|
470
|
+
|
|
297
471
|
enter: (ctx, { send }) => {
|
|
298
|
-
// When watch becomes truthy
|
|
299
|
-
const timer = setTimeout(() => send('
|
|
300
|
-
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
|
|
301
475
|
},
|
|
302
|
-
|
|
303
|
-
|
|
476
|
+
|
|
477
|
+
exit: (ctx, { send }) => {
|
|
478
|
+
// When watch value becomes truthy → falsy
|
|
479
|
+
send('CLEAR_RESULTS')
|
|
304
480
|
},
|
|
481
|
+
|
|
305
482
|
change: (ctx, prev, curr, { send }) => {
|
|
306
|
-
//
|
|
483
|
+
// When watch value changes
|
|
307
484
|
console.log(`${prev} → ${curr}`)
|
|
308
485
|
},
|
|
309
486
|
},
|
|
310
487
|
]
|
|
311
488
|
```
|
|
312
489
|
|
|
313
|
-
|
|
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
|
|
500
|
+
|
|
501
|
+
Rules automatically evaluated whenever context changes:
|
|
502
|
+
|
|
503
|
+
```ts
|
|
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 |
|
|
314
524
|
|
|
315
|
-
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
## State-based Handlers (FSM)
|
|
528
|
+
|
|
529
|
+
Execute different handlers based on state:
|
|
316
530
|
|
|
317
531
|
```ts
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
events: { FETCH: undefined; RETRY: undefined }
|
|
321
|
-
state: 'idle' | 'loading' | 'error'
|
|
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'
|
|
322
536
|
}>({
|
|
537
|
+
internal: { state: 'idle', data: null },
|
|
538
|
+
|
|
323
539
|
states: {
|
|
324
540
|
idle: {
|
|
325
|
-
on: {
|
|
541
|
+
on: {
|
|
542
|
+
FETCH: (_, __, assign) => assign({ state: 'loading' }),
|
|
543
|
+
},
|
|
326
544
|
},
|
|
327
545
|
loading: {
|
|
328
|
-
|
|
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
|
+
},
|
|
329
556
|
},
|
|
330
557
|
error: {
|
|
331
|
-
on: {
|
|
558
|
+
on: {
|
|
559
|
+
RETRY: (_, __, assign) => assign({ state: 'loading' }),
|
|
560
|
+
},
|
|
332
561
|
},
|
|
333
562
|
},
|
|
334
|
-
actions: { startFetch: (ctx) => ctx.setState('loading') },
|
|
335
563
|
})
|
|
336
564
|
```
|
|
337
565
|
|
|
338
|
-
|
|
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
|
|
339
574
|
|
|
340
|
-
|
|
575
|
+
Override actions/guards per component:
|
|
341
576
|
|
|
342
577
|
```ts
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
+
}
|
|
347
615
|
```
|
|
348
616
|
|
|
349
617
|
---
|
|
@@ -353,18 +621,36 @@ always: [
|
|
|
353
621
|
Use without React:
|
|
354
622
|
|
|
355
623
|
```ts
|
|
356
|
-
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
|
+
})
|
|
357
636
|
|
|
358
|
-
// Send events
|
|
359
|
-
machine.send('
|
|
637
|
+
// Send events (input required)
|
|
638
|
+
machine.send('INCREMENT', { multiplier: 2 })
|
|
639
|
+
|
|
640
|
+
// Get snapshot
|
|
641
|
+
const snapshot = machine.getSnapshot({ multiplier: 2 })
|
|
642
|
+
console.log(snapshot.count) // 1
|
|
643
|
+
console.log(snapshot.doubled) // 2
|
|
360
644
|
|
|
361
645
|
// Evaluate effects
|
|
362
|
-
machine.evaluate(
|
|
646
|
+
machine.evaluate({ multiplier: 2 })
|
|
363
647
|
|
|
364
|
-
//
|
|
365
|
-
|
|
648
|
+
// Internal state management
|
|
649
|
+
machine.getInternal() // { count: 1 }
|
|
650
|
+
machine.setInternal({ count: 0 }) // Reset
|
|
651
|
+
machine.getInitialInternal() // { count: 0 }
|
|
366
652
|
|
|
367
|
-
// Cleanup
|
|
653
|
+
// Cleanup effects
|
|
368
654
|
machine.cleanup()
|
|
369
655
|
```
|
|
370
656
|
|
|
@@ -374,24 +660,36 @@ machine.cleanup()
|
|
|
374
660
|
|
|
375
661
|
### Type Parameters
|
|
376
662
|
|
|
377
|
-
Specify only what you need:
|
|
378
|
-
|
|
379
663
|
```ts
|
|
380
|
-
// Minimal
|
|
381
664
|
createMachine<{
|
|
382
|
-
input:
|
|
383
|
-
|
|
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
|
|
384
672
|
}>({ ... })
|
|
673
|
+
```
|
|
385
674
|
|
|
386
|
-
|
|
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
|
|
387
688
|
createMachine<{
|
|
388
|
-
input:
|
|
389
|
-
|
|
390
|
-
computed: MyComputed
|
|
391
|
-
actions: 'action1' | 'action2'
|
|
392
|
-
guards: 'guard1' | 'guard2'
|
|
393
|
-
state: 'idle' | 'active'
|
|
689
|
+
input: { count: number }
|
|
690
|
+
internal: { count: string } // ❌ 'count' key duplicated
|
|
394
691
|
}>({ ... })
|
|
692
|
+
// → Context type becomes 'never', causing error
|
|
395
693
|
```
|
|
396
694
|
|
|
397
695
|
### Exports
|
|
@@ -400,13 +698,16 @@ createMachine<{
|
|
|
400
698
|
import { createMachine, effect } from 'controlled-machine'
|
|
401
699
|
import { useMachine } from 'controlled-machine/react'
|
|
402
700
|
import type {
|
|
701
|
+
MachineTypes,
|
|
403
702
|
Machine,
|
|
703
|
+
MachineInstance,
|
|
704
|
+
Context,
|
|
705
|
+
Snapshot,
|
|
404
706
|
Send,
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
UseMachineOptions,
|
|
707
|
+
Input,
|
|
708
|
+
Internal,
|
|
709
|
+
Computed,
|
|
710
|
+
AssignFn,
|
|
410
711
|
} from 'controlled-machine'
|
|
411
712
|
```
|
|
412
713
|
|