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 CHANGED
@@ -1,301 +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
+ ```
8
12
 
9
- A reusable dropdown machine — logic lives in the machine, DOM handling in your component:
13
+ ---
10
14
 
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'
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
- 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),
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
- <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 |
186
328
 
187
- ## Features
329
+ ### Inline Function
188
330
 
189
- ### Conditional Handlers
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
- Branch logic with `when`. Stops at first match:
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
- TOGGLE: [
196
- { when: (ctx) => ctx.disabled, do: [] }, // Function guard
197
- { when: 'isOpen', do: 'close' }, // String guard (from guards config)
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
- ### Multiple Actions
356
+ ### Rule Array (Conditional Branching)
204
357
 
205
- Execute multiple actions per event:
358
+ Only the first rule with matching `when` condition executes (first match wins):
206
359
 
207
360
  ```ts
208
361
  on: {
209
- SELECT: ['highlight', 'select', 'close'], // Array of actions
210
-
211
- CONFIRM: [
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
- ### Computed Values
369
+ ### Named Actions
219
370
 
220
- Derive values from input:
371
+ Define reusable actions:
221
372
 
222
373
  ```ts
223
- computed: {
224
- isEmpty: (input) => input.items.length === 0,
225
- canSubmit: (input) => input.value.length > 0 && !input.isLoading,
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
- // Use in handlers
388
+ ### Mixing Functions and Strings
389
+
390
+ **Only possible inside Rule's `do`:**
391
+
392
+ ```ts
229
393
  on: {
230
- SUBMIT: [
231
- { when: (ctx) => !ctx.canSubmit, do: [] },
232
- { do: 'submit' },
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
- // Access from hook
237
- const { computed } = useMachine(machine, { input })
238
- if (computed.isEmpty) { /* ... */ }
455
+ // Usage
456
+ const [snapshot] = useMachine(machine, { input: { items } })
457
+ console.log(snapshot.selectedItem) // Access computed value
239
458
  ```
240
459
 
241
- ### Effects
460
+ ---
461
+
462
+ ## Effects
242
463
 
243
- Watch value changes and react:
464
+ Detect value changes and execute side effects:
244
465
 
245
466
  ```ts
246
467
  effects: [
247
468
  {
248
- watch: (ctx) => ctx.highlightedId,
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('AUTO_SELECT'), 1000)
252
- 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
253
475
  },
254
- exit: () => {
255
- // When watch becomes falsy
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
- // On any change
483
+ // When watch value changes
259
484
  console.log(`${prev} → ${curr}`)
260
485
  },
261
486
  },
262
487
  ]
263
488
  ```
264
489
 
265
- ### 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
266
500
 
267
- Different handlers per state:
501
+ Rules automatically evaluated whenever context changes:
268
502
 
269
503
  ```ts
270
- const machine = createMachine<{
271
- input: { state: 'idle' | 'loading' | 'error'; setState: (s) => void }
272
- events: { FETCH: undefined; RETRY: undefined }
273
- state: 'idle' | 'loading' | 'error'
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: { FETCH: 'startFetch' },
541
+ on: {
542
+ FETCH: (_, __, assign) => assign({ state: 'loading' }),
543
+ },
278
544
  },
279
545
  loading: {
280
- // 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
+ },
281
556
  },
282
557
  error: {
283
- on: { RETRY: 'startFetch' },
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
- ### 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
291
574
 
292
- Auto-evaluated on every context change:
575
+ Override actions/guards per component:
293
576
 
294
577
  ```ts
295
- always: [
296
- { when: (ctx) => ctx.value < 0, do: 'clampToMin' },
297
- { when: (ctx) => ctx.value > 100, do: 'clampToMax' },
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({ /* 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
+ })
636
+
637
+ // Send events (input required)
638
+ machine.send('INCREMENT', { multiplier: 2 })
309
639
 
310
- // Send events
311
- machine.send('OPEN', { isOpen: false, onOpenChange: (v) => { /* ... */ } })
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(input)
646
+ machine.evaluate({ multiplier: 2 })
315
647
 
316
- // Get computed values
317
- 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 }
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: MyInput
335
- 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
336
672
  }>({ ... })
673
+ ```
337
674
 
338
- // 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
339
688
  createMachine<{
340
- input: MyInput
341
- events: MyEvents
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 { Machine, Send, Rule, Handler, UseMachineOptions } from 'controlled-machine'
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
  ---