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 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