@typeonce/effect-machine 0.28.0 → 0.30.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.
@@ -52,8 +52,8 @@ const SelectorProjectionTypeId = "~effect/reactivity/AtomMachine/SelectorProject
52
52
  * Selects the typed value for an active state path.
53
53
  *
54
54
  * Valid paths and their selected value types are inferred from the bridge.
55
- * The derived atom suppresses structurally equal updates. Keep the returned
56
- * atom stable when constructing it inside a component.
55
+ * The derived atom suppresses structurally equal updates. Repeated calls with
56
+ * the same bridge and path return the same atom.
57
57
  *
58
58
  * **Example**
59
59
  *
@@ -87,8 +87,8 @@ export const select = dual(2, internal.select);
87
87
  * Selects the typed logical snapshot for an active state path.
88
88
  *
89
89
  * Unlike {@link select}, the selected value retains its child snapshot
90
- * topology. The derived atom suppresses structurally equal updates. Keep the
91
- * returned atom stable when constructing it inside a component.
90
+ * topology. The derived atom suppresses structurally equal updates. Repeated
91
+ * calls with the same bridge and path return the same atom.
92
92
  *
93
93
  * @category combinators
94
94
  * @since 0.7.0
@@ -98,8 +98,8 @@ export const selectSnapshot = dual(2, internal.selectSnapshot);
98
98
  * Selects the typed value for an active state path in a directly owned child.
99
99
  *
100
100
  * Valid paths and their selected value types are inferred from the child
101
- * bridge. An inactive child produces `Option.none()`. Keep the returned atom
102
- * stable when constructing it inside a component.
101
+ * bridge. An inactive child produces `Option.none()`. Repeated calls with the
102
+ * same child bridge and path return the same atom.
103
103
  *
104
104
  * **Example**
105
105
  *
@@ -118,7 +118,8 @@ export const selectChild = dual(2, internal.selectChild);
118
118
  *
119
119
  * An inactive child or state path produces `Option.none()`. Unlike
120
120
  * {@link selectChild}, the selected value retains its child snapshot topology.
121
- * The derived atom suppresses structurally equal updates.
121
+ * The derived atom suppresses structurally equal updates. Repeated calls with
122
+ * the same child bridge and path return the same atom.
122
123
  *
123
124
  * @category combinators
124
125
  * @since 0.7.0
@@ -128,8 +129,9 @@ export const selectSnapshotChild = dual(2, internal.selectSnapshotChild);
128
129
  * Returns whether a state path is active.
129
130
  *
130
131
  * Valid paths are inferred from the bridge snapshot.
131
- * The derived atom suppresses equal updates. Runtime failures remain in the
132
- * typed failure channel.
132
+ * The derived atom suppresses equal updates. Repeated calls with the same
133
+ * bridge and path return the same atom. Runtime failures remain in the typed
134
+ * failure channel.
133
135
  *
134
136
  * **Example**
135
137
  *
@@ -161,8 +163,8 @@ export const matches = dual(2, internal.matches);
161
163
  * Returns whether a state path is active in a directly owned child.
162
164
  *
163
165
  * Valid paths are inferred from the child bridge snapshot.
164
- * An inactive child produces `false`. Keep the returned atom stable when
165
- * constructing it inside a component.
166
+ * An inactive child produces `false`. Repeated calls with the same child
167
+ * bridge and path return the same atom.
166
168
  *
167
169
  * @category combinators
168
170
  * @since 0.4.0
@@ -255,6 +257,26 @@ export const familyChild = internal.familyChild;
255
257
  * @since 0.4.0
256
258
  */
257
259
  export const make = internal.make;
260
+ /**
261
+ * Specializes a machine definition into a reusable bridge constructor.
262
+ *
263
+ * The returned function preserves the machine's startup input arity and exact
264
+ * bridge type. Every call creates a fresh `MachineAtom`; it does not cache by
265
+ * input or start the machine before an `AtomRegistry` reads or mounts it.
266
+ *
267
+ * **Example**
268
+ *
269
+ * ```ts
270
+ * const makeSearchMachine = AtomMachine.factory(searchMachine)
271
+ * const search = makeSearchMachine({ query: "effect" })
272
+ *
273
+ * type SearchMachineAtom = ReturnType<typeof makeSearchMachine>
274
+ * ```
275
+ *
276
+ * @category constructors
277
+ * @since 0.30.0
278
+ */
279
+ export const factory = internal.factory;
258
280
  /**
259
281
  * Creates a lazy atom bridge from a decoded logical snapshot.
260
282
  *
@@ -267,11 +289,12 @@ export const make = internal.make;
267
289
  */
268
290
  export const resume = internal.resume;
269
291
  /**
270
- * Creates an `AtomMachine` factory that owns a shared Effect runtime.
292
+ * Binds `AtomMachine` constructors to a shared Effect runtime.
271
293
  *
272
294
  * Use this when an application runs many machines from the same service layer.
273
- * The returned factory keeps runtime provisioning at the composition boundary,
274
- * while every call to `make` still creates an independent machine bridge.
295
+ * The returned interface keeps runtime provisioning at the composition seam,
296
+ * while every call to `make` or a specialized `factory` still creates an
297
+ * independent machine bridge.
275
298
  *
276
299
  * @category constructors
277
300
  * @since 0.4.0
@@ -1 +1 @@
1
- {"version":3,"file":"AtomMachine.js","sourceRoot":"","sources":["../../../src/unstable/reactivity/AtomMachine.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAA;AAMtC,OAAO,KAAK,QAAQ,MAAM,gCAAgC,CAAA;AAK1D;;;;;GAKG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAA;AAE9D;;;;;GAKG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AAMpE,MAAM,0BAA0B,GAAG,qDAAqD,CAAA;AAoIxF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,SAAS,GAE+C,QAAQ,CAAC,SAAS,CAAA;AAEvF;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,UAAU,GAE+D,QAAQ,CAAC,UAAU,CAAA;AAEzG;;;;;GAKG;AACH,MAAM,CAAC,MAAM,cAAc,GAMvB,QAAQ,CAAC,cAAc,CAAA;AAwJ3B,MAAM,yBAAyB,GAAG,oDAAoD,CAAA;AACtF,MAAM,wBAAwB,GAAG,mDAAmD,CAAA;AA0BpF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,MAAM,CAAC,MAAM,MAAM,GA0Cf,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAA;AAE5B;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,cAAc,GA0CvB,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAA;AAEpC;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,WAAW,GAsCpB,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,WAAW,CAAC,CAAA;AAEjC;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAoC5B,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,mBAAmB,CAAC,CAAA;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,CAAC,MAAM,OAAO,GAsChB,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;AAE7B;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,YAAY,GA2BrB,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,YAAY,CAAC,CAAA;AAElC,MAAM,uBAAuB,GAAG,kDAAkD,CAAA;AAkJlF,MAAM,yBAAyB,GAAG,oDAAoD,CAAA;AAyFtF;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,CAAC,MAAM,MAAM,GAkBgE,QAAQ,CAAC,MAAa,CAAA;AAEzG;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,CAAC,MAAM,WAAW,GAYqC,QAAQ,CAAC,WAAkB,CAAA;AAExF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,CAAC,MAAM,IAAI,GAqDb,QAAQ,CAAC,IAAI,CAAA;AAEjB;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,MAAM,GASf,QAAQ,CAAC,MAAM,CAAA;AAEnB;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,IAAI,GAEoB,QAAQ,CAAC,IAAI,CAAA"}
1
+ {"version":3,"file":"AtomMachine.js","sourceRoot":"","sources":["../../../src/unstable/reactivity/AtomMachine.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAA;AAMtC,OAAO,KAAK,QAAQ,MAAM,gCAAgC,CAAA;AAK1D;;;;;GAKG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAA;AAE9D;;;;;GAKG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAA;AAMpE,MAAM,0BAA0B,GAAG,qDAAqD,CAAA;AAoIxF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,SAAS,GAE+C,QAAQ,CAAC,SAAS,CAAA;AAEvF;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,UAAU,GAE+D,QAAQ,CAAC,UAAU,CAAA;AAEzG;;;;;GAKG;AACH,MAAM,CAAC,MAAM,cAAc,GAMvB,QAAQ,CAAC,cAAc,CAAA;AAwJ3B,MAAM,yBAAyB,GAAG,oDAAoD,CAAA;AACtF,MAAM,wBAAwB,GAAG,mDAAmD,CAAA;AA0BpF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,MAAM,CAAC,MAAM,MAAM,GA0Cf,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAA;AAE5B;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,cAAc,GA0CvB,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAA;AAEpC;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,WAAW,GAsCpB,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,WAAW,CAAC,CAAA;AAEjC;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAoC5B,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,mBAAmB,CAAC,CAAA;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,MAAM,CAAC,MAAM,OAAO,GAsChB,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;AAE7B;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,YAAY,GA2BrB,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,YAAY,CAAC,CAAA;AAElC,MAAM,uBAAuB,GAAG,kDAAkD,CAAA;AAkJlF,MAAM,yBAAyB,GAAG,oDAAoD,CAAA;AAyGtF;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,CAAC,MAAM,MAAM,GAkBgE,QAAQ,CAAC,MAAa,CAAA;AAEzG;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,CAAC,MAAM,WAAW,GAYqC,QAAQ,CAAC,WAAkB,CAAA;AAExF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,CAAC,MAAM,IAAI,GAqDb,QAAQ,CAAC,IAAI,CAAA;AAEjB;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,CAAC,MAAM,OAAO,GAM+C,QAAQ,CAAC,OAAO,CAAA;AAEnF;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,MAAM,GASf,QAAQ,CAAC,MAAM,CAAA;AAEnB;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,IAAI,GAEoB,QAAQ,CAAC,IAAI,CAAA"}
@@ -1,208 +1,343 @@
1
- # Effect Atom and React patterns
1
+ # Effect Atom and React
2
2
 
3
- This guide records the folder organization and four 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"
50
+
51
+ const makeAuthMachine = MachineAtoms.factory(AuthMachine)
52
+ type AuthMachineAtom = ReturnType<typeof makeAuthMachine>
53
+
54
+ const AuthMachineContext = createContext<AuthMachineAtom | null>(null)
64
55
 
65
- export const counterMachineAtom = machineAtoms.make(counterMachine)
56
+ export function AuthMachineProvider({
57
+ children,
58
+ input
59
+ }: {
60
+ readonly children: ReactNode
61
+ readonly input: AuthMachineInput
62
+ }) {
63
+ const machine = useMachineAtom(() => makeAuthMachine(input))
64
+
65
+ return (
66
+ <AuthMachineContext.Provider value={machine}>
67
+ {children}
68
+ </AuthMachineContext.Provider>
69
+ )
70
+ }
66
71
 
67
- export const counterStateAtom = AtomMachine.select(
68
- counterMachineAtom,
69
- "counter"
70
- )
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
+ }
71
79
  ```
72
80
 
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.
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.
76
85
 
77
- ## 2. A keyed machine with startup input
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:
78
89
 
79
- `AtomMachine.family` uses the machine input as both startup input and family
80
- key. It returns one direct atom family for each entry in `atoms`:
90
+ ```tsx
91
+ <AuthMachineProvider key={attemptId} input={input}>
92
+ <AuthCard />
93
+ </AuthMachineProvider>
94
+ ```
81
95
 
82
- ```ts
83
- import { machineAtoms } from "@/lib/atom-runtime"
96
+ Put the owner above a Suspense boundary. React can then retain the same machine
97
+ while a state-reading descendant suspends.
98
+
99
+ ## Render state-owned data
100
+
101
+ Subscribe in the smallest component that renders a state path:
102
+
103
+ ```tsx
104
+ import { useAtomSuspense } from "@effect/atom-react"
84
105
  import { AtomMachine } from "@typeonce/effect-machine/reactivity"
85
- import { processMachine } 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
+ ```
86
120
 
87
- export const processAtoms = machineAtoms.family(processMachine, {
88
- atoms: {
89
- details: AtomMachine.select("process"),
90
- result: AtomMachine.select("process.Ready"),
91
- send: (machine) => machine.send
92
- },
93
- label: (input, name) => `process:${input.query}:${name}`
94
- })
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.
125
+
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
+ })
143
+ }
95
144
  ```
96
145
 
97
- React consumes each projected family directly:
146
+ Place independent subscriptions in independent descendants:
98
147
 
99
148
  ```tsx
100
- const input = { query }
101
- const details = useAtomValue(processAtoms.details(input))
102
- const send = useAtomSet(processAtoms.send(input))
149
+ function AuthCard() {
150
+ return (
151
+ <>
152
+ <EditingFields />
153
+ <VerificationFields />
154
+ <FailureMessage />
155
+ <SubmitButton />
156
+ </>
157
+ )
158
+ }
103
159
  ```
104
160
 
105
- Each public atom retains its private machine bridge. Keeping only `details` or
106
- only `send` is safe. The bridge still starts lazily in the registry and stops
107
- when that registry releases or disposes it. A writable source remains writable,
108
- and a projection keeps the source atom's equality function.
161
+ Atom granularity cannot isolate hooks that all live in `AuthCard`. Any selected
162
+ change rerenders the component that called the hook.
163
+
164
+ ## Send without subscribing
165
+
166
+ Use the writable atom directly:
167
+
168
+ ```tsx
169
+ import { useAtomSet } from "@effect/atom-react"
109
170
 
110
- The family uses Effect `Equal` and `Hash` semantics. Equal records such as
111
- `{ query: "effect" }` select the same family value even when reconstructed.
112
- Keep inputs immutable because mutating a hashed key makes later lookup
113
- unreliable. Different input values select independent machines. If a changing
114
- value should update one running workflow, model the change as an event instead
115
- of putting it in the machine input.
171
+ function SubmitButton() {
172
+ const machine = useAuthMachine()
173
+ const send = useAtomSet(machine.send)
116
174
 
117
- Service-free machines use the module function directly:
175
+ return (
176
+ <button onClick={() => send({ _tag: "Submitted" })}>
177
+ Continue
178
+ </button>
179
+ )
180
+ }
181
+ ```
182
+
183
+ `useAtomSet` mounts the writable atom and does not subscribe the component to
184
+ its value.
185
+
186
+ ## Query concrete event acceptance
187
+
188
+ `Machine.can` composes with `machine.snapshot` through a derived atom. Declare
189
+ the projection once for a module-level machine, or create it once alongside a
190
+ React-owned machine:
191
+
192
+ ```tsx
193
+ import { Effect, Equal } from "effect"
194
+ import { Atom } from "effect/unstable/reactivity"
195
+ import { Machine } from "@typeonce/effect-machine"
196
+ import { useAtomSet, useAtomSuspense } from "@effect/atom-react"
197
+ import { useState } from "react"
198
+
199
+ const makeCanSubmitAtom = (machine: AuthMachineAtom) =>
200
+ Atom.make((get) =>
201
+ get.result(machine.snapshot).pipe(
202
+ Effect.flatMap((snapshot) => {
203
+ if (snapshot.status === "active") {
204
+ return Machine.can(AuthMachine, snapshot.state, { _tag: "Submitted" })
205
+ }
206
+ if (snapshot.status === "error") {
207
+ return Effect.failCause(snapshot.cause)
208
+ }
209
+ return Effect.succeed(false)
210
+ })
211
+ )
212
+ ).pipe(Atom.withEquality(Equal.equals))
213
+
214
+ function SubmitButton() {
215
+ const machine = useAuthMachine()
216
+ const [canSubmitAtom] = useState(() => makeCanSubmitAtom(machine))
217
+ const canSubmit = useAtomSuspense(canSubmitAtom).value
218
+ const send = useAtomSet(machine.send)
219
+
220
+ return (
221
+ <button
222
+ disabled={!canSubmit}
223
+ onClick={() => send({ _tag: "Submitted" })}
224
+ >
225
+ Continue
226
+ </button>
227
+ )
228
+ }
229
+ ```
230
+
231
+ Startup still suspends, startup and runtime failures reach the error boundary,
232
+ and invalid event input remains a `MachineSchemaDecodeError`. Done and stopped
233
+ machines return `false`. When the event payload itself changes reactively, read
234
+ it from another atom inside the same derived atom.
235
+
236
+ ## Whole-result and custom selections
237
+
238
+ Reading the full result is correct when a component renders the complete
239
+ machine state:
240
+
241
+ ```tsx
242
+ function AuthScreen() {
243
+ const machine = useAuthMachine()
244
+ const state = useAtomSuspense(machine.result).value
245
+
246
+ return AuthStates.match(state, {
247
+ Editing: (editing) => <EditingScreen state={editing} />,
248
+ Verification: (verification) => <VerificationScreen state={verification} />,
249
+ Failed: (failed) => <FailureScreen state={failed} />
250
+ })
251
+ }
252
+ ```
253
+
254
+ That component rerenders for every result change. Current
255
+ `@effect/atom-react` does not select from the successful value in
256
+ `useAtomSuspense`. Until it does, use typed path selectors for state-owned UI,
257
+ or declare a custom derived atom once in a strongly owned scope. Do not create
258
+ a fresh derived atom on every render.
259
+
260
+ ## Share a keyed machine outside one React owner
261
+
262
+ `AtomMachine.family` is for registry-owned machines that unrelated consumers
263
+ find by startup input. It is not the default for one React-owned workflow.
118
264
 
119
265
  ```ts
120
- export const processAtoms = AtomMachine.family(processMachine, {
266
+ export const processAtoms = MachineAtoms.family(ProcessMachine, {
121
267
  atoms: {
122
- details: AtomMachine.select("process"),
268
+ details: AtomMachine.select("Processing"),
269
+ ready: AtomMachine.matches("Ready"),
123
270
  send: (machine) => machine.send
124
271
  }
125
272
  })
126
273
  ```
127
274
 
128
- ## 3. Reusing one machine definition for multiple instances
275
+ Consumers use the input as the shared identity key:
129
276
 
130
- Define the dialog adapter once:
277
+ ```tsx
278
+ const details = useAtomSuspense(processAtoms.details(input)).value
279
+ const send = useAtomSet(processAtoms.send(input))
280
+ ```
131
281
 
132
- ```ts
133
- import { machineAtoms } from "@/lib/atom-runtime"
134
- import { AtomMachine } from "@typeonce/effect-machine/reactivity"
135
- import { dialogMachine } from "./machine"
282
+ Each public projection retains its private machine owner. Keeping only
283
+ `details(input)` or `send(input)` is safe. Do not return a weakly held composite
284
+ scope and retain only one field from it.
136
285
 
137
- export function makeDialogScope() {
138
- const machine = machineAtoms.make(dialogMachine)
286
+ Family keys use Effect `Equal` and `Hash` semantics. Keep them immutable. If a
287
+ changing value should update one running workflow, model it as an event instead
288
+ of changing the family key.
139
289
 
140
- return {
141
- isOpenAtom: AtomMachine.matches(machine, "Open"),
142
- isClosedAtom: AtomMachine.matches(machine, "Closed"),
143
- openStateAtom: AtomMachine.select(machine, "Open"),
144
- sendAtom: machine.send
145
- }
146
- }
290
+ ## Module-owned machines
291
+
292
+ A no-input machine may intentionally have one module-owned identity:
147
293
 
148
- export type DialogScope = ReturnType<typeof makeDialogScope>
294
+ ```ts
295
+ export const CounterMachineAtom = MachineAtoms.make(CounterMachine)
296
+ export const CounterStateAtom = AtomMachine.select(CounterMachineAtom, "Count")
149
297
  ```
150
298
 
151
- ### React-tree-owned instance
299
+ Every consumer using the same `AtomRegistry` reaches the same running machine.
300
+ Different registries still run independent instances.
152
301
 
153
- ```tsx
154
- const DialogContext = createContext<DialogScope | null>(null)
302
+ ## Child machines
155
303
 
156
- export function DialogProvider({ children }: { children: ReactNode }) {
157
- const [scope] = useState(makeDialogScope)
304
+ Direct child selectors follow the active child and preserve inactivity:
158
305
 
159
- return (
160
- <DialogContext.Provider value={scope}>
161
- {children}
162
- </DialogContext.Provider>
163
- )
164
- }
306
+ ```tsx
307
+ const editor = machine.child(Editor)
308
+ const editing = useAtomSuspense(
309
+ AtomMachine.selectSnapshotChild(editor, "Editing")
310
+ ).value
165
311
  ```
166
312
 
167
- Each provider owns one independent dialog. Descendants use a small
168
- `useDialog()` hook and subscribe to the focused atom they need. Pass
169
- `DialogScope` through props when Context is unnecessary. Do not add a wrapper
170
- component whose only job is forwarding the scope.
313
+ An inactive child or path returns `Option.none()`. Re-entry follows the
314
+ replacement child instance.
171
315
 
172
- For a no-input machine, use one module-level bridge or an explicitly owned
173
- React scope. Do not add a family key that the machine does not consume. When an
174
- ID is part of startup semantics, declare it in the machine input and use
175
- `AtomMachine.family`.
176
-
177
- ## 4. Selecting process-owned child machines
178
-
179
- Bind a machine definition once when a parent owns a runtime-sized set of child
180
- machines:
316
+ Use `AtomMachine.familyChild` when a parent owns a runtime-sized set of keyed
317
+ children:
181
318
 
182
319
  ```ts
183
- const Plant = Machine.childFamily(plantMachine)
184
-
185
- export const centralMachineAtom = machineAtoms.make(centralMachine)
320
+ const Plant = Machine.childFamily(PlantMachine)
186
321
 
187
- export const plantAtoms = AtomMachine.familyChild(centralMachineAtom, {
322
+ export const plantAtoms = AtomMachine.familyChild(CentralMachineAtom, {
188
323
  child: (plantId: string) => Plant(plantId),
189
324
  atoms: {
325
+ broken: AtomMachine.matchesChild("Broken"),
190
326
  state: (plant) => plant.state,
191
- isBroken: AtomMachine.matchesChild("Broken"),
192
- send: (plant) => plant.send,
193
- stop: (plant) => plant.stop
327
+ send: (plant) => plant.send
194
328
  }
195
329
  })
196
-
197
- const broken = useAtomValue(plantAtoms.isBroken(plantId))
198
- const send = useAtomSet(plantAtoms.send(plantId))
199
330
  ```
200
331
 
201
- `familyChild` keeps child lookup separate from root machine startup. Each
202
- projected atom retains the child bridge returned for its key.
332
+ ## Registry and rendering semantics
333
+
334
+ A `MachineAtom` identifies one machine per `AtomRegistry`. Passing the same
335
+ machine atom through two registry providers creates two independent runtimes.
336
+ Unmounting a React owner releases its mount. The registry stops the machine
337
+ after its final subscription and configured idle retention expire.
338
+ `registry.dispose()` stops it immediately.
203
339
 
204
- `Plant(plantId)` may be reconstructed wherever the id is available. Child
205
- lookup and bridge reuse match by machine identity and id, not descriptor object
206
- identity. Before the parent spawns that child, selectors contain `Option.none`
207
- and `matchesChild` is `false`. They follow the child after startup and return to
208
- the inactive values after it stops.
340
+ `useMachineAtom` does not start a machine during server rendering because
341
+ React effects do not run on the server. Reading a machine atom during server
342
+ render follows `@effect/atom-react` server-read behavior, so choose an explicit
343
+ client boundary when server startup would be undesirable.
@@ -65,11 +65,25 @@ 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
- ## Use retained families for keyed machine input
68
+ ## Choose React ownership or keyed family lookup
69
69
 
70
- Effect Atom keeps a family value for an equal key while that returned value is
71
- reachable. Current runtimes may hold family values through `WeakRef`. Retaining
72
- one field from a composite family value does not retain the composite itself:
70
+ Use `useMachineAtom` when one React subtree owns the workflow, including a
71
+ machine with startup input:
72
+
73
+ ```tsx
74
+ const makeProcessMachine = machineAtoms.factory(processMachine)
75
+ const machine = useMachineAtom(() => makeProcessMachine(input))
76
+ ```
77
+
78
+ Pass the stable machine through props or Context. Startup input is captured
79
+ once. Send an event to change the running workflow, or change the owner's React
80
+ key to replace it.
81
+
82
+ Use `AtomMachine.family` when unrelated consumers must find one shared machine
83
+ by its startup input. Effect Atom keeps a family value for an equal key while
84
+ that returned value is reachable. Current runtimes may hold family values
85
+ through `WeakRef`. Retaining one field from a composite family value does not
86
+ retain the composite itself:
73
87
 
74
88
  ```ts
75
89
  // Unsafe when consumers retain only stateAtom or sendAtom
@@ -82,8 +96,8 @@ const processScope = Atom.family((input: ProcessInput) => {
82
96
  })
83
97
  ```
84
98
 
85
- Use `AtomMachine.family` for an input-bearing machine. It returns direct atom
86
- families whose atoms retain the private machine bridge:
99
+ `AtomMachine.family` returns direct atom families whose atoms retain the
100
+ private machine bridge:
87
101
 
88
102
  ```ts
89
103
  export const processAtoms = machineAtoms.family(processMachine, {
@@ -101,14 +115,14 @@ No component `useMemo` is needed. The registry retains the public atom while a
101
115
  hook subscribes to it, and that atom retains the machine owner. Equal inputs
102
116
  use Effect `Equal` and `Hash` semantics and select the same family value.
103
117
 
104
- For a no-input machine, use one module-level bridge or a lazy
105
- `useState(makeScope)` value owned by a React subtree. Do not add an unused key.
118
+ For a no-input machine, use one module-level bridge or `useMachineAtom` in the
119
+ owning React subtree. Do not add an unused family key.
106
120
 
107
121
  Review check: search for composite `Atom.family` values that own a machine,
108
- `useMemo` around family lookup, and component-local calls to
109
- `machineAtoms.make`. Replace an input-bearing machine with
110
- `AtomMachine.family`. Give a no-input instance an explicit module or React-tree
111
- owner.
122
+ `useMemo` around family lookup, repeated input propagation through one React
123
+ subtree, and component-local calls to `machineAtoms.make` without a stable
124
+ owner. Choose `AtomMachine.family` only when consumers need shared keyed
125
+ lookup.
112
126
 
113
127
  ## Justify each `RegistryProvider`
114
128