controlled-machine 0.1.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 +572 -0
- package/dist/index.cjs +160 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +143 -0
- package/dist/index.d.ts +143 -0
- package/dist/index.js +160 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +103 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +100 -0
- package/dist/react.d.ts +100 -0
- package/dist/react.js +103 -0
- package/dist/react.js.map +1 -0
- package/package.json +75 -0
package/README.md
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
# Controlled Machine
|
|
2
|
+
|
|
3
|
+
A controlled state machine where state lives outside the machine.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
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 })
|
|
27
|
+
|
|
28
|
+
return <button onClick={() => send('OPEN')}>Open</button>
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Introduction
|
|
35
|
+
|
|
36
|
+
Controlled Machine maintains the core concepts of state machines (conditional transitions, side effects) while **keeping state external**.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// XState: state lives inside the machine
|
|
40
|
+
const machine = createMachine({
|
|
41
|
+
initial: 'closed',
|
|
42
|
+
states: {
|
|
43
|
+
closed: { on: { OPEN: 'open' } },
|
|
44
|
+
open: { on: { CLOSE: 'closed' } },
|
|
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'
|
|
106
|
+
}>({
|
|
107
|
+
on: {
|
|
108
|
+
OPEN: 'open',
|
|
109
|
+
CLOSE: 'close',
|
|
110
|
+
SELECT: 'select',
|
|
111
|
+
},
|
|
112
|
+
actions: {
|
|
113
|
+
open: (ctx) => ctx.setIsOpen(true),
|
|
114
|
+
close: (ctx) => ctx.setIsOpen(false),
|
|
115
|
+
select: (ctx, payload) => {
|
|
116
|
+
ctx.setSelectedId(payload.itemId)
|
|
117
|
+
ctx.setIsOpen(false)
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Send Events
|
|
124
|
+
|
|
125
|
+
Use `useMachine` in React components.
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
import { useMachine } from 'controlled-machine/react'
|
|
129
|
+
|
|
130
|
+
function Dropdown() {
|
|
131
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
132
|
+
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
133
|
+
|
|
134
|
+
const { send } = useMachine(machine, {
|
|
135
|
+
isOpen,
|
|
136
|
+
setIsOpen,
|
|
137
|
+
selectedId,
|
|
138
|
+
setSelectedId,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div>
|
|
143
|
+
<button onClick={() => send('OPEN')}>Open</button>
|
|
144
|
+
{isOpen && (
|
|
145
|
+
<ul>
|
|
146
|
+
<li onClick={() => send('SELECT', { itemId: '1' })}>Item 1</li>
|
|
147
|
+
</ul>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## API Reference
|
|
157
|
+
|
|
158
|
+
### `createMachine<T>(config)`
|
|
159
|
+
|
|
160
|
+
Creates a machine definition with the given configuration.
|
|
161
|
+
|
|
162
|
+
**Type Parameters:**
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
type MachineTypes = {
|
|
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
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**Config:**
|
|
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 |
|
|
184
|
+
|
|
185
|
+
### `useMachine(machine, input)`
|
|
186
|
+
|
|
187
|
+
React hook that connects a machine to component state.
|
|
188
|
+
|
|
189
|
+
**Returns:**
|
|
190
|
+
|
|
191
|
+
| Property | Description |
|
|
192
|
+
|----------|-------------|
|
|
193
|
+
| `send` | Function to dispatch events |
|
|
194
|
+
| `computed` | Computed values derived from input |
|
|
195
|
+
| `state` | Current state (if using state-based structure) |
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Conditional Handlers
|
|
200
|
+
|
|
201
|
+
Use `when` conditions to branch logic. Stops at the first match.
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
on: {
|
|
205
|
+
TOGGLE: [
|
|
206
|
+
{ when: (ctx) => ctx.disabled, do: 'noop' },
|
|
207
|
+
{ when: (ctx) => ctx.isOpen, do: 'close' },
|
|
208
|
+
{ do: 'open' }, // default case
|
|
209
|
+
],
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Multiple Actions
|
|
216
|
+
|
|
217
|
+
Execute multiple actions per event.
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
on: {
|
|
221
|
+
// Single action
|
|
222
|
+
OPEN: 'open',
|
|
223
|
+
|
|
224
|
+
// Multiple actions (array)
|
|
225
|
+
CLOSE: ['clearHighlight', 'close'],
|
|
226
|
+
|
|
227
|
+
// Conditional with multiple actions
|
|
228
|
+
SELECT: [
|
|
229
|
+
{ when: (ctx) => ctx.disabled, do: 'noop' },
|
|
230
|
+
{ do: ['highlight', 'select', 'close'] },
|
|
231
|
+
],
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## State-based Structure
|
|
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)
|
|
244
|
+
|
|
245
|
+
Derive state from existing values. This aligns with the "controlled" philosophy—state is computed from the source of truth.
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
// Async data fetching example
|
|
249
|
+
const machine = createMachine<{
|
|
250
|
+
input: {
|
|
251
|
+
data: Item[] | null
|
|
252
|
+
isLoading: boolean
|
|
253
|
+
error: Error | null
|
|
254
|
+
setData: (data: Item[] | null) => void
|
|
255
|
+
setIsLoading: (v: boolean) => void
|
|
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'
|
|
262
|
+
}>({
|
|
263
|
+
computed: {
|
|
264
|
+
state: (input) => {
|
|
265
|
+
if (input.isLoading) return 'loading'
|
|
266
|
+
if (input.error) return 'error'
|
|
267
|
+
if (input.data) return 'success'
|
|
268
|
+
return 'idle'
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
states: {
|
|
272
|
+
idle: {
|
|
273
|
+
on: { FETCH: 'fetch' },
|
|
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
|
+
},
|
|
284
|
+
},
|
|
285
|
+
actions: {
|
|
286
|
+
fetch: async (ctx) => {
|
|
287
|
+
ctx.setIsLoading(true)
|
|
288
|
+
ctx.setError(null)
|
|
289
|
+
// fetch logic...
|
|
290
|
+
},
|
|
291
|
+
retry: (ctx) => {
|
|
292
|
+
ctx.setError(null)
|
|
293
|
+
ctx.setIsLoading(true)
|
|
294
|
+
// retry logic...
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
// React: manage individual values, state is derived
|
|
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)
|
|
304
|
+
|
|
305
|
+
const { send, state } = useMachine(machine, {
|
|
306
|
+
data, setData, isLoading, setIsLoading, error, setError,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<div>
|
|
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
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Approach 2: Direct State in Input
|
|
321
|
+
|
|
322
|
+
Use when state is explicitly managed as a single value.
|
|
323
|
+
|
|
324
|
+
```ts
|
|
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
|
+
})
|
|
356
|
+
|
|
357
|
+
// React: manage state directly
|
|
358
|
+
function Modal() {
|
|
359
|
+
const [state, setState] = useState<'closed' | 'opening' | 'open' | 'closing'>('closed')
|
|
360
|
+
const { send } = useMachine(machine, { state, setState })
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<div
|
|
364
|
+
className={`modal modal--${state}`}
|
|
365
|
+
onAnimationEnd={() => send('ANIMATION_END')}
|
|
366
|
+
>
|
|
367
|
+
<button onClick={() => send('CLOSE')}>Close</button>
|
|
368
|
+
</div>
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Combining with Global Handlers
|
|
374
|
+
|
|
375
|
+
State handlers run first, then global handlers:
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
{
|
|
379
|
+
states: {
|
|
380
|
+
idle: { on: { LOG: 'logIdle' } },
|
|
381
|
+
active: { on: { LOG: 'logActive' } },
|
|
382
|
+
},
|
|
383
|
+
on: {
|
|
384
|
+
LOG: 'logGlobal', // Always runs after state handler
|
|
385
|
+
},
|
|
386
|
+
}
|
|
387
|
+
// idle + LOG → logIdle, then logGlobal
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
---
|
|
391
|
+
|
|
392
|
+
## Effects
|
|
393
|
+
|
|
394
|
+
Watch value changes and react to them. Access `send` in callbacks.
|
|
395
|
+
|
|
396
|
+
```ts
|
|
397
|
+
effects: [
|
|
398
|
+
{
|
|
399
|
+
watch: (ctx) => ctx.hoveredId,
|
|
400
|
+
enter: (ctx, { send }) => {
|
|
401
|
+
// Called when watch value becomes truthy
|
|
402
|
+
const timer = setTimeout(() => send('OPEN'), 300)
|
|
403
|
+
return () => clearTimeout(timer) // cleanup
|
|
404
|
+
},
|
|
405
|
+
exit: (ctx, { send }) => {
|
|
406
|
+
// Called when watch value becomes falsy
|
|
407
|
+
send('CLOSE')
|
|
408
|
+
},
|
|
409
|
+
change: (ctx, prev, curr, { send }) => {
|
|
410
|
+
// Called on any change
|
|
411
|
+
console.log(`Changed from ${prev} to ${curr}`)
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
]
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### Async Operations with Cleanup
|
|
418
|
+
|
|
419
|
+
Handle async requests and race conditions:
|
|
420
|
+
|
|
421
|
+
```ts
|
|
422
|
+
effects: [
|
|
423
|
+
{
|
|
424
|
+
watch: (ctx) => ctx.searchQuery,
|
|
425
|
+
change: (ctx, prev, curr, { send }) => {
|
|
426
|
+
const controller = new AbortController()
|
|
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
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
]
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### Effect Helper Function
|
|
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
|
|
457
|
+
|
|
458
|
+
Derive values from input. Available in handlers and returned from the hook.
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
const machine = createMachine<{
|
|
462
|
+
input: Input
|
|
463
|
+
events: Events
|
|
464
|
+
computed: { isEmpty: boolean; displayValue: string }
|
|
465
|
+
}>({
|
|
466
|
+
computed: {
|
|
467
|
+
isEmpty: (ctx) => ctx.items.length === 0,
|
|
468
|
+
displayValue: (ctx) => ctx.selectedItem?.label ?? ctx.inputValue,
|
|
469
|
+
},
|
|
470
|
+
on: {
|
|
471
|
+
CLEAR: [
|
|
472
|
+
{ when: (ctx) => ctx.isEmpty, do: 'noop' }, // Use computed in handlers
|
|
473
|
+
{ do: 'clear' },
|
|
474
|
+
],
|
|
475
|
+
},
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
// Access computed values
|
|
479
|
+
const { computed } = useMachine(machine, input)
|
|
480
|
+
if (computed.isEmpty) { /* ... */ }
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
---
|
|
484
|
+
|
|
485
|
+
## Always Rules
|
|
486
|
+
|
|
487
|
+
Rules evaluated automatically on every context change.
|
|
488
|
+
|
|
489
|
+
```ts
|
|
490
|
+
always: [
|
|
491
|
+
{ when: (ctx) => ctx.value < 0, do: 'resetToZero' },
|
|
492
|
+
{ when: (ctx) => ctx.value > 100, do: 'capToMax' },
|
|
493
|
+
]
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
---
|
|
497
|
+
|
|
498
|
+
## Vanilla JavaScript Usage
|
|
499
|
+
|
|
500
|
+
Use without React:
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
import { createMachine } from 'controlled-machine'
|
|
504
|
+
|
|
505
|
+
const machine = createMachine<{ /* types */ }>({
|
|
506
|
+
on: { /* handlers */ },
|
|
507
|
+
actions: { /* actions */ },
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
// Send events with input
|
|
511
|
+
machine.send('OPEN', { isOpen: false, setIsOpen: (v) => { /* ... */ } })
|
|
512
|
+
|
|
513
|
+
// Evaluate effects
|
|
514
|
+
machine.evaluate(input)
|
|
515
|
+
|
|
516
|
+
// Get computed values
|
|
517
|
+
const computed = machine.getComputed(input)
|
|
518
|
+
|
|
519
|
+
// Cleanup effects on unmount
|
|
520
|
+
machine.cleanup()
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
---
|
|
524
|
+
|
|
525
|
+
## TypeScript
|
|
526
|
+
|
|
527
|
+
### Object-based Generic Types
|
|
528
|
+
|
|
529
|
+
Specify only the types you need in any order:
|
|
530
|
+
|
|
531
|
+
```ts
|
|
532
|
+
// Minimal
|
|
533
|
+
createMachine<{
|
|
534
|
+
input: MyInput
|
|
535
|
+
events: MyEvents
|
|
536
|
+
}>({ /* ... */ })
|
|
537
|
+
|
|
538
|
+
// Full
|
|
539
|
+
createMachine<{
|
|
540
|
+
input: MyInput
|
|
541
|
+
events: MyEvents
|
|
542
|
+
computed: MyComputed
|
|
543
|
+
actions: 'action1' | 'action2'
|
|
544
|
+
state: 'idle' | 'loading' | 'open'
|
|
545
|
+
}>({ /* ... */ })
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
### Type Exports
|
|
549
|
+
|
|
550
|
+
```ts
|
|
551
|
+
import type {
|
|
552
|
+
MachineTypes,
|
|
553
|
+
Machine,
|
|
554
|
+
Send,
|
|
555
|
+
Effect,
|
|
556
|
+
Rule,
|
|
557
|
+
Handler,
|
|
558
|
+
} from 'controlled-machine'
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
---
|
|
562
|
+
|
|
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
|
+
## License
|
|
571
|
+
|
|
572
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
function effect(config) {
|
|
4
|
+
return config;
|
|
5
|
+
}
|
|
6
|
+
function executeActions(actionNames, actions, context, payload) {
|
|
7
|
+
if (typeof actionNames === "string") {
|
|
8
|
+
actions[actionNames]?.(context, payload);
|
|
9
|
+
} else {
|
|
10
|
+
for (const name of actionNames) {
|
|
11
|
+
actions[name]?.(context, payload);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function isRuleArray(handler) {
|
|
16
|
+
return Array.isArray(handler) && handler.length > 0 && typeof handler[0] === "object" && "do" in handler[0];
|
|
17
|
+
}
|
|
18
|
+
function executeHandler(handler, actions, context, payload) {
|
|
19
|
+
if (typeof handler === "string" || Array.isArray(handler) && !isRuleArray(handler)) {
|
|
20
|
+
executeActions(handler, actions, context, payload);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
for (const rule of handler) {
|
|
24
|
+
if (!rule.when || rule.when(context, payload)) {
|
|
25
|
+
executeActions(rule.do, actions, context, payload);
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function computeValues(context, computed) {
|
|
31
|
+
if (!computed) return context;
|
|
32
|
+
const values = {};
|
|
33
|
+
for (const key in computed) {
|
|
34
|
+
values[key] = computed[key](context);
|
|
35
|
+
}
|
|
36
|
+
return { ...context, ...values };
|
|
37
|
+
}
|
|
38
|
+
function shallowEqual(a, b) {
|
|
39
|
+
if (a === b) return true;
|
|
40
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
41
|
+
if (a.length !== b.length) return false;
|
|
42
|
+
return a.every((v, i) => v === b[i]);
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
function createEffectStore() {
|
|
47
|
+
return {
|
|
48
|
+
watchedValues: /* @__PURE__ */ new Map(),
|
|
49
|
+
enterCleanups: /* @__PURE__ */ new Map(),
|
|
50
|
+
changeCleanups: /* @__PURE__ */ new Map(),
|
|
51
|
+
exitCleanups: /* @__PURE__ */ new Map()
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function processEffects(effects, context, effectHelpers, store) {
|
|
55
|
+
if (!effects) return;
|
|
56
|
+
effects.forEach((effect2, i) => {
|
|
57
|
+
const prev = store.watchedValues.get(i);
|
|
58
|
+
const curr = effect2.watch(context);
|
|
59
|
+
if (!shallowEqual(prev, curr)) {
|
|
60
|
+
const enterCleanup = store.enterCleanups.get(i);
|
|
61
|
+
if (enterCleanup) {
|
|
62
|
+
enterCleanup();
|
|
63
|
+
store.enterCleanups.delete(i);
|
|
64
|
+
}
|
|
65
|
+
const changeCleanup = store.changeCleanups.get(i);
|
|
66
|
+
if (changeCleanup) {
|
|
67
|
+
changeCleanup();
|
|
68
|
+
store.changeCleanups.delete(i);
|
|
69
|
+
}
|
|
70
|
+
const changeResult = effect2.change?.(context, prev, curr, effectHelpers);
|
|
71
|
+
if (typeof changeResult === "function") {
|
|
72
|
+
store.changeCleanups.set(i, changeResult);
|
|
73
|
+
}
|
|
74
|
+
if (!prev && curr) {
|
|
75
|
+
const exitCleanup = store.exitCleanups.get(i);
|
|
76
|
+
if (exitCleanup) {
|
|
77
|
+
exitCleanup();
|
|
78
|
+
store.exitCleanups.delete(i);
|
|
79
|
+
}
|
|
80
|
+
const enterResult = effect2.enter?.(context, effectHelpers);
|
|
81
|
+
if (typeof enterResult === "function") {
|
|
82
|
+
store.enterCleanups.set(i, enterResult);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (prev && !curr) {
|
|
86
|
+
const exitResult = effect2.exit?.(context, effectHelpers);
|
|
87
|
+
if (typeof exitResult === "function") {
|
|
88
|
+
store.exitCleanups.set(i, exitResult);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
store.watchedValues.set(i, curr);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function clearEffectStore(store) {
|
|
96
|
+
store.enterCleanups.forEach((fn) => fn());
|
|
97
|
+
store.enterCleanups.clear();
|
|
98
|
+
store.changeCleanups.forEach((fn) => fn());
|
|
99
|
+
store.changeCleanups.clear();
|
|
100
|
+
store.exitCleanups.forEach((fn) => fn());
|
|
101
|
+
store.exitCleanups.clear();
|
|
102
|
+
store.watchedValues.clear();
|
|
103
|
+
}
|
|
104
|
+
function createMachine(config) {
|
|
105
|
+
const effectStore = createEffectStore();
|
|
106
|
+
const send = ((event, input, ...args) => {
|
|
107
|
+
const context = computeValues(input, config.computed);
|
|
108
|
+
const payload = args[0];
|
|
109
|
+
const state = context.state;
|
|
110
|
+
if (state && config.states?.[state]?.on?.[event]) {
|
|
111
|
+
const stateHandler = config.states[state].on[event];
|
|
112
|
+
executeHandler(stateHandler, config.actions ?? {}, context, payload);
|
|
113
|
+
}
|
|
114
|
+
const globalHandler = config.on?.[event];
|
|
115
|
+
if (globalHandler) {
|
|
116
|
+
executeHandler(globalHandler, config.actions ?? {}, context, payload);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
const createEffectHelpersWithInput = (input) => ({
|
|
120
|
+
send: ((event, ...args) => {
|
|
121
|
+
send(event, input, ...args);
|
|
122
|
+
})
|
|
123
|
+
});
|
|
124
|
+
const evaluate = (input) => {
|
|
125
|
+
const context = computeValues(input, config.computed);
|
|
126
|
+
const effectHelpers = createEffectHelpersWithInput(input);
|
|
127
|
+
if (config.always && config.actions) {
|
|
128
|
+
const actionsMap = config.actions;
|
|
129
|
+
for (const rule of config.always) {
|
|
130
|
+
if (!rule.when || rule.when(context, void 0)) {
|
|
131
|
+
executeActions(rule.do, actionsMap, context, void 0);
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
processEffects(config.effects, context, effectHelpers, effectStore);
|
|
137
|
+
};
|
|
138
|
+
const getComputed = (input) => {
|
|
139
|
+
const context = computeValues(input, config.computed);
|
|
140
|
+
if (!config.computed) return {};
|
|
141
|
+
const result = {};
|
|
142
|
+
for (const key in config.computed) {
|
|
143
|
+
result[key] = context[key];
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
};
|
|
147
|
+
const cleanup = () => clearEffectStore(effectStore);
|
|
148
|
+
return Object.assign(config, { send, evaluate, getComputed, cleanup });
|
|
149
|
+
}
|
|
150
|
+
exports.clearEffectStore = clearEffectStore;
|
|
151
|
+
exports.computeValues = computeValues;
|
|
152
|
+
exports.createEffectStore = createEffectStore;
|
|
153
|
+
exports.createMachine = createMachine;
|
|
154
|
+
exports.effect = effect;
|
|
155
|
+
exports.executeActions = executeActions;
|
|
156
|
+
exports.executeHandler = executeHandler;
|
|
157
|
+
exports.isRuleArray = isRuleArray;
|
|
158
|
+
exports.processEffects = processEffects;
|
|
159
|
+
exports.shallowEqual = shallowEqual;
|
|
160
|
+
//# sourceMappingURL=index.cjs.map
|