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 CHANGED
@@ -1,349 +1,617 @@
1
1
  # Controlled Machine
2
2
 
3
- A controlled state machine where **state lives outside the machine**.
3
+ Manage your React UI state cleanly.
4
4
 
5
- Machine defines **what** happens. Your component owns **the state**.
5
+ - Separate logic from UI
6
+ - Declarative state transitions
7
+ - Full TypeScript support
6
8
 
7
- ## The Killer Example
9
+ ```bash
10
+ npm install controlled-machine
11
+ ```
12
+
13
+ ---
8
14
 
9
- A reusable dropdown machine — logic lives in the machine, DOM handling in your component:
15
+ ## Why?
10
16
 
11
- ```ts
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'
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
- OPEN: [{ when: 'isOpen', do: [] }, { do: 'open' }],
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
- // Dropdown.tsx Component provides DOM implementation
37
- function Dropdown() {
38
- const [isOpen, setIsOpen] = useState(false)
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
- const { send } = useMachine(dropdownMachine, {
42
- input: { isOpen, onOpenChange: setIsOpen },
43
- actions: {
44
- // Override: provide actual DOM implementation
45
- focusTrigger: () => triggerRef.current?.focus(),
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
- <button ref={triggerRef} onClick={() => send('TOGGLE')}>
52
- Menu
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
- {isOpen && (
55
- <ul>
56
- <li onClick={() => send('CLOSE')}>Item 1</li>
57
- </ul>
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
- **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
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
- ## Installation
213
+ **Features demonstrated:**
73
214
 
74
- ```bash
75
- npm install controlled-machine
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
- ### 1. External State
229
+ ### Internal vs Input
83
230
 
84
- Unlike XState where state lives inside the machine, here **you own the state**:
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
- ```tsx
87
- // Your state, your control
88
- const [isOpen, setIsOpen] = useState(false)
89
- const [selectedId, setSelectedId] = useState<string | null>(null)
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
- // Machine just defines handlers
92
- const { send } = useMachine(machine, {
93
- input: { isOpen, onOpenChange: setIsOpen, selectedId, onSelect: setSelectedId },
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
- ### 2. Declarative Handlers
273
+ ### Context
98
274
 
99
- Define **what** happens on each event with conditional logic:
275
+ Inside handlers, `input + internal + computed` are merged into one flat object:
100
276
 
101
277
  ```ts
102
278
  on: {
103
- SELECT: [
104
- { when: 'isDisabled', do: [] }, // Guard: skip if disabled
105
- { when: 'hasSelection', do: 'deselect' }, // Conditional action
106
- { do: ['select', 'close'] }, // Default: multiple actions
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
- ### 3. Actions & Guards Override
288
+ ### Snapshot
112
289
 
113
- Machine provides defaults. Component can override:
290
+ The value returned by `useMachine`. Contains **Internal + Computed** (excludes Input):
114
291
 
115
- ```ts
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
- })
292
+ ```tsx
293
+ const [snapshot, send] = useMachine(machine, { input: { value, items } })
126
294
 
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
- })
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
- ### `createMachine<T>(config)`
302
+ Function to update internal state. The third argument in handlers:
145
303
 
146
304
  ```ts
147
- const machine = createMachine<{
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
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
- guards: {
167
- canIncrement: (ctx) => ctx.count < 10,
309
+ RESET: (_, __, assign) => {
310
+ assign({ isOpen: false, highlightedIndex: -1 }) // Multiple keys
168
311
  },
169
- })
312
+ }
170
313
  ```
171
314
 
172
- ### `useMachine(machine, options)`
315
+ ---
173
316
 
174
- ```ts
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
- send('INCREMENT')
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
- ## Features
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
- ### Conditional Handlers
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
- Branch logic with `when`/`do` rules. Stops at first match:
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
- TOGGLE: [
196
- { when: 'isDisabled', do: [] }, // Skip if disabled
197
- { when: 'isOpen', do: 'close' }, // Close if open
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
- ### Guards
356
+ ### Rule Array (Conditional Branching)
204
357
 
205
- Use named guards, inline functions, or arrays in `when`:
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: 'close' }, // Named guard
211
- { when: (ctx) => ctx.disabled, do: [] }, // Inline guard function
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
- ### Inline Actions
369
+ ### Named Actions
226
370
 
227
- Use inline functions directly in `do`:
371
+ Define reusable actions:
228
372
 
229
373
  ```ts
230
- on: {
231
- SELECT: [
232
- {
233
- when: 'isEnabled',
234
- do: (ctx, payload) => ctx.selectItem(payload.id), // Single inline action
235
- },
236
- ],
237
-
238
- SUBMIT: [
239
- {
240
- when: 'isValid',
241
- do: [
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
- ### Multiple Actions
388
+ ### Mixing Functions and Strings
252
389
 
253
- Execute multiple actions per event:
390
+ **Only possible inside Rule's `do`:**
254
391
 
255
392
  ```ts
256
393
  on: {
257
- SELECT: ['highlight', 'select', 'close'], // Array of named actions
394
+ // Doesn't work - mixing at handler level
395
+ EVENT: [(ctx) => console.log(ctx), 'actionName'],
258
396
 
259
- CONFIRM: [
260
- { when: 'isValid', do: ['save', 'close', 'notify'] },
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
- ### Computed Values
402
+ ---
403
+
404
+ ## Guards
267
405
 
268
- Derive values from input:
406
+ Guard functions for conditional execution:
269
407
 
270
408
  ```ts
271
- computed: {
272
- isEmpty: (input) => input.items.length === 0,
273
- canSubmit: (input) => input.value.length > 0 && !input.isLoading,
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
- // Use in handlers
277
- on: {
278
- SUBMIT: [
279
- { when: (ctx) => !ctx.canSubmit, do: [] },
280
- { do: 'submit' },
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
- // Access from hook
285
- const { computed } = useMachine(machine, { input })
286
- if (computed.isEmpty) { /* ... */ }
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
- ### Effects
460
+ ---
461
+
462
+ ## Effects
290
463
 
291
- Watch value changes and react:
464
+ Detect value changes and execute side effects:
292
465
 
293
466
  ```ts
294
467
  effects: [
295
468
  {
296
- watch: (ctx) => ctx.highlightedId,
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('AUTO_SELECT'), 1000)
300
- return () => clearTimeout(timer) // Cleanup
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
- exit: () => {
303
- // When watch becomes falsy
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
- // On any change
483
+ // When watch value changes
307
484
  console.log(`${prev} → ${curr}`)
308
485
  },
309
486
  },
310
487
  ]
311
488
  ```
312
489
 
313
- ### State-based Handlers
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
- Different handlers per state:
525
+ ---
526
+
527
+ ## State-based Handlers (FSM)
528
+
529
+ Execute different handlers based on state:
316
530
 
317
531
  ```ts
318
- const machine = createMachine<{
319
- input: { state: 'idle' | 'loading' | 'error'; setState: (s) => void }
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: { FETCH: 'startFetch' },
541
+ on: {
542
+ FETCH: (_, __, assign) => assign({ state: 'loading' }),
543
+ },
326
544
  },
327
545
  loading: {
328
- // FETCH ignored while loading
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: { RETRY: 'startFetch' },
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
- ### Always Rules
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
- Auto-evaluated on every context change:
575
+ Override actions/guards per component:
341
576
 
342
577
  ```ts
343
- always: [
344
- { when: (ctx) => ctx.value < 0, do: 'clampToMin' },
345
- { when: (ctx) => ctx.value > 100, do: 'clampToMax' },
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({ /* config */ })
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('OPEN', { isOpen: false, onOpenChange: (v) => { /* ... */ } })
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(input)
646
+ machine.evaluate({ multiplier: 2 })
363
647
 
364
- // Get computed values
365
- const computed = machine.getComputed(input)
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: MyInput
383
- events: MyEvents
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
- // Full
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: MyInput
389
- events: MyEvents
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
- Rule,
406
- Handler,
407
- ActionItem, // Named action or inline function
408
- GuardItem, // Named guard or inline function
409
- UseMachineOptions,
707
+ Input,
708
+ Internal,
709
+ Computed,
710
+ AssignFn,
410
711
  } from 'controlled-machine'
411
712
  ```
412
713