@typeonce/effect-machine 0.27.1 → 0.29.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.
@@ -1,230 +1,293 @@
1
- # Effect Atom and React patterns
1
+ # Effect Atom and React
2
2
 
3
- This guide records the folder organization and three integration patterns
4
- validated in the process app. Use the API reference for individual AtomMachine
5
- operations. Read the [Effect Machine agent guide](./agent-guide.md) for
6
- statechart modeling, transitions, services, and testing.
3
+ React code should own one stable machine atom, pass it through props or
4
+ Context, and subscribe in the descendants that render machine state. Keep the
5
+ machine definition free of React dependencies.
6
+
7
+ Read the [Effect Machine agent guide](./agent-guide.md) for statechart
8
+ modeling, transitions, services, and testing.
7
9
 
8
10
  ## Recommended folder structure
9
11
 
10
12
  ```text
11
13
  src/
12
- ├── context/ # Optional React Context adapters
13
- ├── dialog-context.tsx
14
- │ └── process-context.tsx
14
+ ├── context/
15
+ └── auth-machine-context.tsx # React ownership and distribution
15
16
  ├── lib/
16
- │ ├── atom-runtime.ts # Shared bound AtomMachine runtime
17
- │ └── services/ # Generic Effect business services
18
- │ └── query-processor.ts
17
+ │ ├── atom-runtime.ts # Shared bound AtomMachine runtime
18
+ │ └── services/
19
19
  └── machines/
20
- ├── counter/
21
- │ ├── machine.ts # Machine implementation
22
- │ └── atom.ts # Focused atoms for React
23
- ├── process/
24
- │ ├── machine.ts
25
- │ └── atom.ts
26
- └── dialog/
27
- ├── machine.ts
28
- └── atom.ts
20
+ └── auth-machine.ts # States, events, and behavior
29
21
  ```
30
22
 
31
- Keep these responsibilities separate:
23
+ `machine.ts` owns the workflow. A Context module only creates and distributes
24
+ the machine atom. State-slot components decide which state paths they render.
32
25
 
33
- - `machine.ts` defines states, events, transitions, statechart behavior, and
34
- Effect service requirements. It has no React dependency.
35
- - `atom.ts` adapts that machine to the shared bound AtomMachine runtime and
36
- exports the focused atoms React needs.
37
- - `lib/services/` contains reusable business services used by machines.
38
- - `context/` is optional. It only distributes an already-created machine scope
39
- through a React subtree.
40
- - `lib/atom-runtime.ts` binds AtomMachine once to the application's Effect
41
- service layer:
26
+ Bind service-backed machines once at the application runtime:
42
27
 
43
28
  ```ts
44
29
  import { AtomMachine } from "@typeonce/effect-machine/reactivity"
45
30
  import { Atom } from "effect/unstable/reactivity"
46
- import { QueryProcessor } from "./services/query-processor"
31
+ import { AppLayer } from "./app-layer"
47
32
 
48
- const atomRuntime = Atom.runtime(QueryProcessor.layer)
33
+ const atomRuntime = Atom.runtime(AppLayer)
49
34
 
50
- export const machineAtoms = AtomMachine.bind(atomRuntime)
35
+ export const MachineAtoms = AtomMachine.bind(atomRuntime)
51
36
  ```
52
37
 
53
- Each `machineAtoms.make` call still creates an independent machine bridge.
38
+ Service-free machines can use `AtomMachine.make` directly.
54
39
 
55
- ## 1. One global actor with no input
40
+ ## Own a machine in one React subtree
56
41
 
57
- Use a module-level bridge when a no-input machine intentionally has one
58
- application-wide instance:
42
+ Use `useMachineAtom` when a provider, route, dialog, or other React subtree
43
+ owns one machine instance:
59
44
 
60
- ```ts
61
- import { machineAtoms } from "@/lib/atom-runtime"
62
- import { AtomMachine } from "@typeonce/effect-machine/reactivity"
63
- import { counterMachine } from "./machine"
45
+ ```tsx
46
+ import { useMachineAtom } from "@typeonce/effect-machine-react"
47
+ import { createContext, type ReactNode, useContext } from "react"
48
+ import { AuthMachine, type AuthMachineInput } from "../machines/auth-machine"
49
+ import { MachineAtoms } from "../lib/atom-runtime"
64
50
 
65
- export const counterMachineAtom = machineAtoms.make(counterMachine)
51
+ const makeAuthMachine = (input: AuthMachineInput) => MachineAtoms.make(AuthMachine, input)
52
+ type AuthMachineAtom = ReturnType<typeof makeAuthMachine>
66
53
 
67
- export const counterStateAtom = AtomMachine.select(
68
- counterMachineAtom,
69
- "counter"
70
- )
71
- ```
54
+ const AuthMachineContext = createContext<AuthMachineAtom | null>(null)
72
55
 
73
- "Global" means every import reaches this bridge under the same atom registry.
74
- Consumers read `counterStateAtom` and use `counterMachineAtom.send` directly.
75
- Do not add a redundant `counterSendAtom` alias.
56
+ export function AuthMachineProvider({
57
+ children,
58
+ input
59
+ }: {
60
+ readonly children: ReactNode
61
+ readonly input: AuthMachineInput
62
+ }) {
63
+ const machine = useMachineAtom(() => makeAuthMachine(input))
76
64
 
77
- ## 2. A machine with startup input selected through a family
65
+ return (
66
+ <AuthMachineContext.Provider value={machine}>
67
+ {children}
68
+ </AuthMachineContext.Provider>
69
+ )
70
+ }
78
71
 
79
- Use the family key as machine identity. The same value can also be startup
80
- input:
72
+ export function useAuthMachine(): AuthMachineAtom {
73
+ const machine = useContext(AuthMachineContext)
74
+ if (machine === null) {
75
+ throw new Error("useAuthMachine must be used inside AuthMachineProvider")
76
+ }
77
+ return machine
78
+ }
79
+ ```
81
80
 
82
- ```ts
83
- import { machineAtoms } from "@/lib/atom-runtime"
84
- import { AtomMachine } from "@typeonce/effect-machine/reactivity"
85
- import { Atom } from "effect/unstable/reactivity"
86
- import { processMachine } from "./machine"
81
+ The provider strongly owns the complete `MachineAtom`. The hook mounts
82
+ `machine.ref` after React commits the owner, but it does not read `state`,
83
+ `snapshot`, or `result`. Machine updates therefore do not rerender the
84
+ provider.
87
85
 
88
- export const processFamily = Atom.family((query: string) => {
89
- const machine = machineAtoms.make(processMachine, { query })
86
+ The factory captures startup input once. A later `input` prop change does not
87
+ replace the running workflow. Send an event when the change belongs to that
88
+ workflow. Change the provider's React key when React should own a new machine:
90
89
 
91
- return {
92
- detailsAtom: AtomMachine.select(machine, "process"),
93
- resultAtom: AtomMachine.select(machine, "process.Ready"),
94
- sendAtom: machine.send
95
- }
96
- })
90
+ ```tsx
91
+ <AuthMachineProvider key={attemptId} input={input}>
92
+ <AuthCard />
93
+ </AuthMachineProvider>
97
94
  ```
98
95
 
99
- A consumer calls `processFamily(query)` and uses the returned focused atoms. If
100
- several nested components need the same scope, an optional Context can expose
101
- `ReturnType<typeof processFamily>`. The provider resolves the query once instead
102
- of drilling it through every component.
103
-
104
- Changing `query` selects another family member and therefore another machine.
105
- If a changing value should update the current workflow, model it as an event.
96
+ Put the owner above a Suspense boundary. React can then retain the same machine
97
+ while a state-reading descendant suspends.
106
98
 
107
- ## 3. Reusing one machine definition for multiple instances
99
+ ## Render state-owned data
108
100
 
109
- Define the dialog adapter once:
101
+ Subscribe in the smallest component that renders a state path:
110
102
 
111
- ```ts
112
- import { machineAtoms } from "@/lib/atom-runtime"
103
+ ```tsx
104
+ import { useAtomSuspense } from "@effect/atom-react"
113
105
  import { AtomMachine } from "@typeonce/effect-machine/reactivity"
114
- import { dialogMachine } from "./machine"
106
+ import { Option } from "effect"
107
+
108
+ function EditingFields() {
109
+ const machine = useAuthMachine()
110
+ const editing = useAtomSuspense(
111
+ AtomMachine.selectSnapshot(machine, "Editing")
112
+ ).value
113
+
114
+ return Option.match(editing, {
115
+ onNone: () => null,
116
+ onSome: ({ value }) => <EmailField email={value.email} />
117
+ })
118
+ }
119
+ ```
115
120
 
116
- export function makeDialogScope() {
117
- const machine = machineAtoms.make(dialogMachine)
121
+ `AtomMachine.select` returns the selected state value.
122
+ `AtomMachine.selectSnapshot` also retains the selected state's child topology.
123
+ Both return `Option.none()` while the path is inactive. Do not replace that
124
+ absence with an empty string, `null`, or a global boolean.
118
125
 
119
- return {
120
- isOpenAtom: AtomMachine.matches(machine, "Open"),
121
- isClosedAtom: AtomMachine.matches(machine, "Closed"),
122
- openStateAtom: AtomMachine.select(machine, "Open"),
123
- sendAtom: machine.send
124
- }
126
+ Repeated calls with the same machine and path return the same atom, so path
127
+ selection is safe during render without `useMemo`. Equal selected values do not
128
+ notify the component.
129
+
130
+ Nested paths keep the same ownership:
131
+
132
+ ```tsx
133
+ function PasswordField() {
134
+ const machine = useAuthMachine()
135
+ const password = useAtomSuspense(
136
+ AtomMachine.select(machine, "Editing.Password")
137
+ ).value
138
+
139
+ return Option.match(password, {
140
+ onNone: () => null,
141
+ onSome: ({ password }) => <input type="password" value={password} />
142
+ })
125
143
  }
144
+ ```
145
+
146
+ Place independent subscriptions in independent descendants:
126
147
 
127
- export type DialogScope = ReturnType<typeof makeDialogScope>
148
+ ```tsx
149
+ function AuthCard() {
150
+ return (
151
+ <>
152
+ <EditingFields />
153
+ <VerificationFields />
154
+ <FailureMessage />
155
+ <SubmitButton />
156
+ </>
157
+ )
158
+ }
128
159
  ```
129
160
 
130
- Choose one of the following ownership forms.
161
+ Atom granularity cannot isolate hooks that all live in `AuthCard`. Any selected
162
+ change rerenders the component that called the hook.
131
163
 
132
- ### React-tree-owned instance
164
+ ## Send without subscribing
165
+
166
+ Use the writable atom directly:
133
167
 
134
168
  ```tsx
135
- const DialogContext = createContext<DialogScope | null>(null)
169
+ import { useAtomSet } from "@effect/atom-react"
136
170
 
137
- export function DialogProvider({ children }: { children: ReactNode }) {
138
- const [scope] = useState(makeDialogScope)
171
+ function SubmitButton() {
172
+ const machine = useAuthMachine()
173
+ const send = useAtomSet(machine.send)
139
174
 
140
175
  return (
141
- <DialogContext.Provider value={scope}>
142
- {children}
143
- </DialogContext.Provider>
176
+ <button onClick={() => send({ _tag: "Submitted" })}>
177
+ Continue
178
+ </button>
144
179
  )
145
180
  }
146
181
  ```
147
182
 
148
- Each provider owns one independent dialog. Descendants use a small
149
- `useDialog()` hook and subscribe to the focused atom they need. Pass
150
- `DialogScope` through props when Context is unnecessary. Do not add a wrapper
151
- component whose only job is forwarding the scope.
183
+ `useAtomSet` mounts the writable atom and does not subscribe the component to
184
+ its value.
152
185
 
153
- ### Stable keyed instances shared across scattered components
186
+ ## Whole-result and custom selections
154
187
 
155
- Use one private family to create the scope for an ID. Public selector families
156
- reach that same scope:
188
+ Reading the full result is correct when a component renders the complete
189
+ machine state:
157
190
 
158
- ```ts
159
- import { Atom } from "effect/unstable/reactivity"
191
+ ```tsx
192
+ function AuthScreen() {
193
+ const machine = useAuthMachine()
194
+ const state = useAtomSuspense(machine.result).value
195
+
196
+ return AuthStates.match(state, {
197
+ Editing: (editing) => <EditingScreen state={editing} />,
198
+ Verification: (verification) => <VerificationScreen state={verification} />,
199
+ Failed: (failed) => <FailureScreen state={failed} />
200
+ })
201
+ }
202
+ ```
160
203
 
161
- const dialogScopeFamily = Atom.family((dialogId: string) => {
162
- const scope = makeDialogScope()
163
-
164
- return {
165
- isOpenAtom: scope.isOpenAtom.pipe(
166
- Atom.withLabel(`dialog:${dialogId}:isOpen-source`)
167
- ),
168
- openStateAtom: scope.openStateAtom.pipe(
169
- Atom.withLabel(`dialog:${dialogId}:openState-source`)
170
- ),
171
- sendAtom: scope.sendAtom.pipe(
172
- Atom.withLabel(`dialog:${dialogId}:send-source`)
173
- )
174
- }
175
- })
204
+ That component rerenders for every result change. Current
205
+ `@effect/atom-react` does not select from the successful value in
206
+ `useAtomSuspense`. Until it does, use typed path selectors for state-owned UI,
207
+ or declare a custom derived atom once in a strongly owned scope. Do not create
208
+ a fresh derived atom on every render.
209
+
210
+ ## Share a keyed machine outside one React owner
176
211
 
177
- export const dialogIsOpenFamily = Atom.family((dialogId: string) => {
178
- const scope = dialogScopeFamily(dialogId)
212
+ `AtomMachine.family` is for registry-owned machines that unrelated consumers
213
+ find by startup input. It is not the default for one React-owned workflow.
179
214
 
180
- return Atom.transform(
181
- scope.sendAtom,
182
- (get) => get(scope.isOpenAtom)
183
- ).pipe(Atom.withLabel(`dialog:${dialogId}:isOpen`))
215
+ ```ts
216
+ export const processAtoms = MachineAtoms.family(ProcessMachine, {
217
+ atoms: {
218
+ details: AtomMachine.select("Processing"),
219
+ ready: AtomMachine.matches("Ready"),
220
+ send: (machine) => machine.send
221
+ }
184
222
  })
223
+ ```
185
224
 
186
- export const dialogOpenStateFamily = Atom.family((dialogId: string) => {
187
- const scope = dialogScopeFamily(dialogId)
225
+ Consumers use the input as the shared identity key:
188
226
 
189
- return Atom.transform(
190
- scope.sendAtom,
191
- (get) => get(scope.openStateAtom)
192
- ).pipe(Atom.withLabel(`dialog:${dialogId}:openState`))
193
- })
227
+ ```tsx
228
+ const details = useAtomSuspense(processAtoms.details(input)).value
229
+ const send = useAtomSet(processAtoms.send(input))
194
230
  ```
195
231
 
196
- Components using the same `dialogId` share one machine. Different IDs create
197
- independent machines. The two public projections let consumers subscribe
198
- independently. Both remain writable through `Atom.transform`, so either can send
199
- the inferred dialog events.
232
+ Each public projection retains its private machine owner. Keeping only
233
+ `details(input)` or `send(input)` is safe. Do not return a weakly held composite
234
+ scope and retain only one field from it.
200
235
 
201
- Use `dialogId` in atom labels for diagnostics. Do not pass it into
202
- `dialogMachine` as unused fake input.
236
+ Family keys use Effect `Equal` and `Hash` semantics. Keep them immutable. If a
237
+ changing value should update one running workflow, model it as an event instead
238
+ of changing the family key.
203
239
 
204
- ## 4. Selecting process-owned child machines
240
+ ## Module-owned machines
205
241
 
206
- Bind a machine definition once when a parent owns a runtime-sized set of child
207
- machines:
242
+ A no-input machine may intentionally have one module-owned identity:
208
243
 
209
244
  ```ts
210
- const Plant = Machine.childFamily(plantMachine)
245
+ export const CounterMachineAtom = MachineAtoms.make(CounterMachine)
246
+ export const CounterStateAtom = AtomMachine.select(CounterMachineAtom, "Count")
247
+ ```
248
+
249
+ Every consumer using the same `AtomRegistry` reaches the same running machine.
250
+ Different registries still run independent instances.
251
+
252
+ ## Child machines
211
253
 
212
- export const centralMachineAtom = machineAtoms.make(centralMachine)
254
+ Direct child selectors follow the active child and preserve inactivity:
213
255
 
214
- export const plantScopeFamily = Atom.family((plantId: string) => {
215
- const plant = centralMachineAtom.child(Plant(plantId))
256
+ ```tsx
257
+ const editor = machine.child(Editor)
258
+ const editing = useAtomSuspense(
259
+ AtomMachine.selectSnapshotChild(editor, "Editing")
260
+ ).value
261
+ ```
262
+
263
+ An inactive child or path returns `Option.none()`. Re-entry follows the
264
+ replacement child instance.
265
+
266
+ Use `AtomMachine.familyChild` when a parent owns a runtime-sized set of keyed
267
+ children:
216
268
 
217
- return {
218
- stateAtom: plant.state,
219
- isBrokenAtom: AtomMachine.matchesChild(plant, "Broken"),
220
- sendAtom: plant.send,
221
- stopAtom: plant.stop
269
+ ```ts
270
+ const Plant = Machine.childFamily(PlantMachine)
271
+
272
+ export const plantAtoms = AtomMachine.familyChild(CentralMachineAtom, {
273
+ child: (plantId: string) => Plant(plantId),
274
+ atoms: {
275
+ broken: AtomMachine.matchesChild("Broken"),
276
+ state: (plant) => plant.state,
277
+ send: (plant) => plant.send
222
278
  }
223
279
  })
224
280
  ```
225
281
 
226
- `Plant(plantId)` may be reconstructed wherever the id is available. Child
227
- lookup and bridge reuse match by machine identity and id, not descriptor object
228
- identity. Before the parent spawns that child, selectors contain `Option.none`
229
- and `matchesChild` is `false`. They follow the child after startup and return to
230
- the inactive values after it stops.
282
+ ## Registry and rendering semantics
283
+
284
+ A `MachineAtom` identifies one machine per `AtomRegistry`. Passing the same
285
+ machine atom through two registry providers creates two independent runtimes.
286
+ Unmounting a React owner releases its mount. The registry stops the machine
287
+ after its final subscription and configured idle retention expire.
288
+ `registry.dispose()` stops it immediately.
289
+
290
+ `useMachineAtom` does not start a machine during server rendering because
291
+ React effects do not run on the server. Reading a machine atom during server
292
+ render follows `@effect/atom-react` server-read behavior, so choose an explicit
293
+ client boundary when server startup would be undesirable.
@@ -65,27 +65,29 @@ const handlers = {
65
65
  Review check: search for `.resolve(...)` callbacks that only return an empty
66
66
  `target.from()` and remove the callback.
67
67
 
68
- ## Let `Atom.family` own keyed identity
68
+ ## Choose React ownership or keyed family lookup
69
69
 
70
- Treat `useMemo` around an atom family lookup as a warning sign. `Atom.family`
71
- already returns the same retained object for the same key, including when
72
- separate components perform the lookup.
70
+ Use `useMachineAtom` when one React subtree owns the workflow, including a
71
+ machine with startup input:
73
72
 
74
73
  ```tsx
75
- // Redundant and local to one component
76
- const scope = useMemo(() => processFamily(processId), [processId])
77
-
78
- // The family owns identity
79
- const scope = processFamily(processId)
74
+ const machine = useMachineAtom(() => machineAtoms.make(processMachine, input))
80
75
  ```
81
76
 
82
- If the component constructs the atoms or machine scope directly, move that
83
- construction into a module-level family:
77
+ Pass the stable machine through props or Context. Startup input is captured
78
+ once. Send an event to change the running workflow, or change the owner's React
79
+ key to replace it.
84
80
 
85
- ```ts
86
- export const processFamily = Atom.family((processId: string) => {
87
- const machine = machineAtoms.make(processMachine, { processId })
81
+ Use `AtomMachine.family` when unrelated consumers must find one shared machine
82
+ by its startup input. Effect Atom keeps a family value for an equal key while
83
+ that returned value is reachable. Current runtimes may hold family values
84
+ through `WeakRef`. Retaining one field from a composite family value does not
85
+ retain the composite itself:
88
86
 
87
+ ```ts
88
+ // Unsafe when consumers retain only stateAtom or sendAtom
89
+ const processScope = Atom.family((input: ProcessInput) => {
90
+ const machine = machineAtoms.make(processMachine, input)
89
91
  return {
90
92
  stateAtom: AtomMachine.select(machine, "process"),
91
93
  sendAtom: machine.send
@@ -93,17 +95,33 @@ export const processFamily = Atom.family((processId: string) => {
93
95
  })
94
96
  ```
95
97
 
96
- Use a stable domain key. A new key means a different machine instance. Send an
97
- event when a value should update the current workflow instead.
98
+ `AtomMachine.family` returns direct atom families whose atoms retain the
99
+ private machine bridge:
100
+
101
+ ```ts
102
+ export const processAtoms = machineAtoms.family(processMachine, {
103
+ atoms: {
104
+ state: AtomMachine.select("process"),
105
+ send: (machine) => machine.send
106
+ }
107
+ })
108
+
109
+ const stateAtom = processAtoms.state(input)
110
+ const sendAtom = processAtoms.send(input)
111
+ ```
112
+
113
+ No component `useMemo` is needed. The registry retains the public atom while a
114
+ hook subscribes to it, and that atom retains the machine owner. Equal inputs
115
+ use Effect `Equal` and `Hash` semantics and select the same family value.
98
116
 
99
- `useMemo` may still be useful for unrelated expensive calculations. It should
100
- not establish atom or machine identity. For one instance owned only by a React
101
- subtree, use a lazy `useState(makeScope)` initializer as described in the React
102
- guide.
117
+ For a no-input machine, use one module-level bridge or `useMachineAtom` in the
118
+ owning React subtree. Do not add an unused family key.
103
119
 
104
- Review check: search for `useMemo` around atom creation, family lookup, or
105
- `machineAtoms.make`. Replace component-local identity with `Atom.family`, or
106
- with an intentional component-owned scope.
120
+ Review check: search for composite `Atom.family` values that own a machine,
121
+ `useMemo` around family lookup, repeated input propagation through one React
122
+ subtree, and component-local calls to `machineAtoms.make` without a stable
123
+ owner. Choose `AtomMachine.family` only when consumers need shared keyed
124
+ lookup.
107
125
 
108
126
  ## Justify each `RegistryProvider`
109
127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeonce/effect-machine",
3
- "version": "0.27.1",
3
+ "version": "0.29.0",
4
4
  "description": "Schema-first state machines and statecharts for Effect",
5
5
  "author": "Sandro Maglione",
6
6
  "repository": {