diff --git a/.changeset/README.md b/.changeset/README.md
index 7ed3085..24ea116 100644
--- a/.changeset/README.md
+++ b/.changeset/README.md
@@ -13,7 +13,7 @@ Do not create a major changeset before 1.0. Backward compatibility is not a desi
## Synchronized package versions
-`@typeonce/effect-machine`, `@typeonce/effect-machine-devtools`, and `@typeonce/oxlint-plugin-effect-machine` belong to the same Changesets fixed group. Keep their package versions equal and use `workspace:^` for the devtools dependency on core. A release affecting any package publishes all three at the same version, so users can select compatible packages by matching their versions.
+`@typeonce/effect-machine`, `@typeonce/effect-machine-react`, `@typeonce/effect-machine-devtools`, and `@typeonce/oxlint-plugin-effect-machine` belong to the same Changesets fixed group. Keep their package versions equal and use `workspace:^` for package dependencies on core. A release affecting any package publishes all four at the same version, so users can select compatible packages by matching their versions.
## Writing changelog entries
diff --git a/.changeset/calm-machines-own.md b/.changeset/calm-machines-own.md
new file mode 100644
index 0000000..40d5bf5
--- /dev/null
+++ b/.changeset/calm-machines-own.md
@@ -0,0 +1,15 @@
+---
+"@typeonce/effect-machine": minor
+"@typeonce/effect-machine-react": minor
+---
+
+Add `@typeonce/effect-machine-react` with `useMachineAtom` for owning and mounting one stable machine atom without subscribing its React owner to machine state.
+
+Typed state-path projections now return the same atom for repeated calls with the same machine and path. Descendants can select state-owned data directly during render:
+
+```tsx
+const machine = useMachineAtom(() => MachineAtoms.make(AuthMachine, input))
+const editing = useAtomSuspense(AtomMachine.selectSnapshot(machine, "Editing")).value
+```
+
+Startup input is captured when React creates the owner. Send an event to update the running workflow, or change the owner's React key to replace the machine.
diff --git a/.changeset/config.json b/.changeset/config.json
index 9130fa7..7454490 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -5,6 +5,7 @@
"fixed": [
[
"@typeonce/effect-machine",
+ "@typeonce/effect-machine-react",
"@typeonce/effect-machine-devtools",
"@typeonce/oxlint-plugin-effect-machine"
]
diff --git a/AGENTS.md b/AGENTS.md
index cd14f8b..cbc568d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,7 +16,7 @@ When compatibility, convenience, concision, and semantic clarity conflict, prefe
## Experimental versioning
- The library is experimental and pre-1.0. Public additions and breaking API changes use a minor changeset; compatible fixes and implementation improvements use a patch changeset. Do not create major changesets before 1.0.
-- `@typeonce/effect-machine`, `@typeonce/effect-machine-devtools`, and `@typeonce/oxlint-plugin-effect-machine` release from one Changesets fixed group and must always have the same version. Keep their package versions, workspace dependency, and release configuration synchronized so users can install matching versions for compatibility.
+- `@typeonce/effect-machine`, `@typeonce/effect-machine-react`, `@typeonce/effect-machine-devtools`, and `@typeonce/oxlint-plugin-effect-machine` release from one Changesets fixed group and must always have the same version. Keep their package versions, workspace dependencies, and release configuration synchronized so users can install matching versions for compatibility.
- Backward compatibility is not currently a design goal. Change or remove an existing API whenever a clearer, safer, smaller long-term design replaces it.
- Do not add deprecated aliases, compatibility wrappers, or parallel APIs solely to preserve an inferior existing design unless the user explicitly requests them.
- Explain the resulting API and direct migration in changesets. Do not use changelog entries to credit an external library or narrate implementation history.
diff --git a/README.md b/README.md
index 0796519..f8bdec5 100644
--- a/README.md
+++ b/README.md
@@ -49,9 +49,10 @@ statechart, and the result runs as an Effect-managed machine.
## Packages
-The workspace publishes three packages at the same version:
+The workspace publishes four packages at the same version:
- [`@typeonce/effect-machine`](./packages/effect-machine/README.md) contains the machine runtime, testing modules, and documentation.
+- [`@typeonce/effect-machine-react`](./packages/effect-machine-react/README.md) owns machine atoms in React without subscribing their owners to machine state.
- [`@typeonce/effect-machine-devtools`](./packages/devtools/README.md) contains the publishable local machine visualizer and CLI.
- [`@typeonce/oxlint-plugin-effect-machine`](./packages/oxlint-plugin/README.md) checks Effect Machine models for common structural mistakes.
diff --git a/package.json b/package.json
index bc0a3d2..b69cde1 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"scripts": {
"build": "pnpm --recursive --filter \"./packages/**\" run build",
"test": "node --expose-gc ./node_modules/vitest/vitest.mjs run",
- "test:types": "pnpm --dir packages/effect-machine test:types",
+ "test:types": "pnpm --recursive --filter \"./packages/effect-machine*\" run test:types",
"devtools": "pnpm --dir packages/devtools dev",
"visualizer": "pnpm devtools",
"visualizer:build": "pnpm --dir packages/devtools build",
@@ -25,9 +25,10 @@
"docs:site:serve": "node scripts/api-reference-site/serve.mjs",
"test:consumer": "node scripts/test-consumer.mjs",
"pack:check": "node scripts/pack-check.mjs",
+ "react:pack-check": "node scripts/react-pack-check.mjs",
"devtools:pack-check": "node scripts/devtools-pack-check.mjs",
"oxlint-plugin:pack-check": "node scripts/oxlint-plugin-pack-check.mjs",
- "check": "pnpm format:check && pnpm check:architecture && pnpm check:ci && pnpm docs:api:check && pnpm docs:site:check && pnpm typecheck && pnpm build && pnpm test && pnpm test:types && pnpm test:consumer && pnpm pack:check && pnpm devtools:pack-check && pnpm oxlint-plugin:pack-check",
+ "check": "pnpm format:check && pnpm check:architecture && pnpm check:ci && pnpm docs:api:check && pnpm docs:site:check && pnpm typecheck && pnpm build && pnpm test && pnpm test:types && pnpm test:consumer && pnpm pack:check && pnpm react:pack-check && pnpm devtools:pack-check && pnpm oxlint-plugin:pack-check",
"changeset": "changeset",
"version-packages": "changeset version && dprint fmt",
"release": "pnpm build && changeset publish"
diff --git a/packages/effect-machine-react/LICENSE b/packages/effect-machine-react/LICENSE
new file mode 100644
index 0000000..afebcef
--- /dev/null
+++ b/packages/effect-machine-react/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Sandro Maglione
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/effect-machine-react/NOTICE b/packages/effect-machine-react/NOTICE
new file mode 100644
index 0000000..b49e70e
--- /dev/null
+++ b/packages/effect-machine-react/NOTICE
@@ -0,0 +1,3 @@
+Portions are adapted from the Effect project, which is distributed under the
+MIT License. See https://github.com/Effect-TS/effect and the source history for
+authorship and provenance.
diff --git a/packages/effect-machine-react/README.md b/packages/effect-machine-react/README.md
new file mode 100644
index 0000000..7832dfc
--- /dev/null
+++ b/packages/effect-machine-react/README.md
@@ -0,0 +1,26 @@
+# @typeonce/effect-machine-react
+
+React ownership hooks for machine atoms created by
+[`@typeonce/effect-machine`](../effect-machine/README.md).
+
+```tsx
+import { useMachineAtom } from "@typeonce/effect-machine-react"
+
+function AuthProvider({ input, children }: Props) {
+ const machine = useMachineAtom(() => MachineAtoms.make(AuthMachine, input))
+
+ return (
+
+ {children}
+
+ )
+}
+```
+
+`useMachineAtom` strongly owns one machine atom, mounts it after commit, and
+does not subscribe the owner to machine state. Descendants subscribe to the
+specific state paths they render with `AtomMachine.select`,
+`AtomMachine.selectSnapshot`, or `AtomMachine.matches`.
+
+Machine input is startup-only. Send an event to update a running workflow, or
+change the provider's React `key` to replace it with a new machine.
diff --git a/packages/effect-machine-react/package.json b/packages/effect-machine-react/package.json
new file mode 100644
index 0000000..b7e74af
--- /dev/null
+++ b/packages/effect-machine-react/package.json
@@ -0,0 +1,74 @@
+{
+ "name": "@typeonce/effect-machine-react",
+ "version": "0.28.0",
+ "description": "React ownership hooks for Effect Machine atoms",
+ "author": "Sandro Maglione",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/typeonce-dev/effect-machine.git",
+ "directory": "packages/effect-machine-react"
+ },
+ "bugs": {
+ "url": "https://github.com/typeonce-dev/effect-machine/issues"
+ },
+ "homepage": "https://github.com/typeonce-dev/effect-machine/tree/main/packages/effect-machine-react#readme",
+ "type": "module",
+ "license": "MIT",
+ "sideEffects": false,
+ "files": [
+ "src/**/*.ts",
+ "dist",
+ "README.md",
+ "LICENSE",
+ "NOTICE"
+ ],
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts"
+ },
+ "./package.json": "./package.json"
+ },
+ "publishConfig": {
+ "access": "public",
+ "provenance": true,
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./package.json": "./package.json"
+ }
+ },
+ "scripts": {
+ "build": "tsc -b tsconfig.build.json",
+ "check": "tsc -b tsconfig.json",
+ "test:types": "tstyche"
+ },
+ "dependencies": {
+ "@typeonce/effect-machine": "workspace:^"
+ },
+ "peerDependencies": {
+ "@effect/atom-react": "4.0.0-rc.112",
+ "effect": "4.0.0-rc.112",
+ "react": ">=19.0.0 <20.0.0",
+ "scheduler": ">=0.25.0 <0.28.0"
+ },
+ "devDependencies": {
+ "@effect/atom-react": "4.0.0-rc.112",
+ "@testing-library/react": "16.3.0",
+ "@types/react": "19.2.16",
+ "@types/react-dom": "19.2.3",
+ "effect": "4.0.0-rc.112",
+ "jsdom": "27.2.0",
+ "react": "19.2.7",
+ "react-dom": "19.2.7",
+ "scheduler": "0.27.0",
+ "tstyche": "7.2.1",
+ "typescript": "6.0.3",
+ "vitest": "4.1.10"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/packages/effect-machine-react/src/MachineAtom.ts b/packages/effect-machine-react/src/MachineAtom.ts
new file mode 100644
index 0000000..ac3be62
--- /dev/null
+++ b/packages/effect-machine-react/src/MachineAtom.ts
@@ -0,0 +1,29 @@
+/**
+ * React ownership for machine atoms.
+ *
+ * @since 0.29.0
+ */
+"use client"
+
+import { useAtomMount } from "@effect/atom-react"
+import type { AtomMachine } from "@typeonce/effect-machine/reactivity"
+import * as React from "react"
+
+type AnyMachineAtom = AtomMachine.MachineAtom
+
+/**
+ * Creates one machine atom for a committed React owner and mounts its machine
+ * reference without subscribing the owner to machine state.
+ *
+ * The factory is startup-only. Later changes to values captured by the factory
+ * do not replace the machine. Send an event to change a running workflow, or
+ * change the owner's React `key` to create a new machine.
+ *
+ * @category hooks
+ * @since 0.29.0
+ */
+export const useMachineAtom = (create: () => A): A => {
+ const [machine] = React.useState(create)
+ useAtomMount(machine.ref)
+ return machine
+}
diff --git a/packages/effect-machine-react/src/index.ts b/packages/effect-machine-react/src/index.ts
new file mode 100644
index 0000000..c19f9ec
--- /dev/null
+++ b/packages/effect-machine-react/src/index.ts
@@ -0,0 +1,7 @@
+/**
+ * React ownership hooks for Effect Machine atoms.
+ *
+ * @since 0.29.0
+ */
+
+export * from "./MachineAtom.js"
diff --git a/packages/effect-machine-react/test/MachineAtom.test.tsx b/packages/effect-machine-react/test/MachineAtom.test.tsx
new file mode 100644
index 0000000..28a5933
--- /dev/null
+++ b/packages/effect-machine-react/test/MachineAtom.test.tsx
@@ -0,0 +1,236 @@
+// @vitest-environment jsdom
+
+import { RegistryContext, useAtomSuspense } from "@effect/atom-react"
+import { act, cleanup, render, screen, waitFor } from "@testing-library/react"
+import { Effect, Option, Schema } from "effect"
+import { AtomRegistry } from "effect/unstable/reactivity"
+import * as React from "react"
+import { renderToString } from "react-dom/server"
+import { afterEach, assert, describe, it } from "vitest"
+import { Machine } from "../../effect-machine/src/index.js"
+import { AtomMachine } from "../../effect-machine/src/unstable/reactivity/index.js"
+import { useMachineAtom } from "../src/index.js"
+
+class Active extends Schema.TaggedClass("Active")("Active", {
+ value: Schema.Number
+}) {}
+class Increment extends Schema.TaggedClass("Increment")("Increment", {}) {}
+
+const States = Machine.states({ Active })
+
+const trackedMachine = (onStart: () => void) =>
+ Machine.make({
+ states: States.states,
+ events: Machine.events(Increment),
+ input: Schema.Number,
+ initial: (to) =>
+ to.Active().resolve(({ input, target }) => {
+ onStart()
+ return target.decoded(new Active({ value: input }))
+ })
+ }).handle({
+ Active: {
+ on: {
+ Increment: (to) =>
+ to.full.Active().resolve(({ state, target }) => target.decoded(new Active({ value: state.value + 1 })))
+ }
+ }
+ })
+
+afterEach(cleanup)
+
+describe("useMachineAtom", () => {
+ it("keeps the owner unsubscribed while a state-path reader updates", async () => {
+ const machine = trackedMachine(() => {})
+ const registry = AtomRegistry.make({ defaultIdleTTL: 1_000 })
+ const makeOwned = () => AtomMachine.make(machine, 0)
+ let current: ReturnType | undefined
+ let ownerRenders = 0
+ let readerRenders = 0
+
+ function Reader({ owned }: { readonly owned: NonNullable }) {
+ readerRenders++
+ const active = useAtomSuspense(AtomMachine.select(owned, "Active")).value
+ return {Option.getOrThrow(active).value}
+ }
+
+ function Owner() {
+ ownerRenders++
+ const owned = useMachineAtom(() => AtomMachine.make(machine, 0))
+ current = owned
+ return (
+
+
+
+ )
+ }
+
+ const view = render(
+
+
+
+ )
+
+ await waitFor(() => assert.strictEqual(screen.getByTestId("value").textContent, "0"))
+ const initialOwnerRenders = ownerRenders
+ const initialReaderRenders = readerRenders
+
+ await act(() => {
+ registry.set(current!.send, new Increment({}))
+ })
+
+ await waitFor(() => assert.strictEqual(screen.getByTestId("value").textContent, "1"))
+ assert.strictEqual(ownerRenders, initialOwnerRenders)
+ assert.ok(readerRenders > initialReaderRenders)
+
+ view.unmount()
+ registry.dispose()
+ })
+
+ it("owns one committed machine without making startup input reactive", async () => {
+ let starts = 0
+ const machine = trackedMachine(() => {
+ starts++
+ })
+ const makeOwned = (input: number) => AtomMachine.make(machine, input)
+ let current: ReturnType | undefined
+ const registry = AtomRegistry.make({ defaultIdleTTL: 1_000 })
+
+ function Owner({ input }: { readonly input: number }) {
+ const owned = useMachineAtom(() => AtomMachine.make(machine, input))
+ React.useEffect(() => {
+ current = owned
+ }, [owned])
+ return null
+ }
+
+ const view = render(
+
+
+
+
+
+ )
+
+ await waitFor(() => assert.strictEqual(starts, 1))
+ const first = current!
+ assert.deepStrictEqual(await Effect.runPromise(AtomRegistry.getResult(registry, first.result)), {
+ path: "Active",
+ value: new Active({ value: 1 })
+ })
+
+ view.rerender(
+
+
+
+
+
+ )
+
+ assert.strictEqual(current, first)
+ assert.strictEqual(starts, 1)
+ assert.strictEqual((await Effect.runPromise(AtomRegistry.getResult(registry, first.result))).value.value, 1)
+
+ view.rerender(
+
+
+
+
+
+ )
+
+ await waitFor(() => assert.strictEqual(starts, 2))
+ assert.notStrictEqual(current, first)
+ assert.strictEqual((await Effect.runPromise(AtomRegistry.getResult(registry, current!.result))).value.value, 2)
+
+ view.unmount()
+ registry.dispose()
+ })
+
+ it("runs the same machine atom independently in each registry", async () => {
+ let starts = 0
+ const machine = trackedMachine(() => {
+ starts++
+ })
+ const bridge = AtomMachine.make(machine, 1)
+ const firstRegistry = AtomRegistry.make({ defaultIdleTTL: 1_000 })
+ const secondRegistry = AtomRegistry.make({ defaultIdleTTL: 1_000 })
+
+ function Owner() {
+ useMachineAtom(() => bridge)
+ return null
+ }
+
+ const view = render(
+ <>
+
+
+
+
+
+
+ >
+ )
+
+ await waitFor(() => assert.strictEqual(starts, 2))
+ const first = await Effect.runPromise(AtomRegistry.getResult(firstRegistry, bridge.ref))
+ const second = await Effect.runPromise(AtomRegistry.getResult(secondRegistry, bridge.ref))
+ assert.notStrictEqual(first, second)
+
+ view.unmount()
+ firstRegistry.dispose()
+ secondRegistry.dispose()
+ })
+
+ it("releases its mount when the owner unmounts", async () => {
+ const machine = trackedMachine(() => {})
+ const registry = AtomRegistry.make({ defaultIdleTTL: 0, timeoutResolution: 1 })
+ const makeOwned = () => AtomMachine.make(machine, 1)
+ let current: ReturnType | undefined
+
+ function Owner() {
+ const owned = useMachineAtom(() => AtomMachine.make(machine, 1))
+ React.useEffect(() => {
+ current = owned
+ }, [owned])
+ return null
+ }
+
+ const view = render(
+
+
+
+ )
+
+ await waitFor(() => assert.ok(current !== undefined))
+ const ref = await Effect.runPromise(AtomRegistry.getResult(registry, current!.ref))
+ view.unmount()
+
+ await waitFor(async () => {
+ assert.strictEqual((await Effect.runPromise(ref.snapshot)).status, "stopped")
+ })
+ registry.dispose()
+ })
+
+ it("does not start the machine during server rendering", () => {
+ let starts = 0
+ const machine = trackedMachine(() => {
+ starts++
+ })
+ const registry = AtomRegistry.make()
+
+ function Owner() {
+ useMachineAtom(() => AtomMachine.make(machine, 1))
+ return null
+ }
+
+ renderToString(
+
+
+
+ )
+
+ assert.strictEqual(starts, 0)
+ registry.dispose()
+ })
+})
diff --git a/packages/effect-machine-react/tsconfig.build.json b/packages/effect-machine-react/tsconfig.build.json
new file mode 100644
index 0000000..0178f60
--- /dev/null
+++ b/packages/effect-machine-react/tsconfig.build.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/effect-machine-react/tsconfig.json b/packages/effect-machine-react/tsconfig.json
new file mode 100644
index 0000000..fbd1768
--- /dev/null
+++ b/packages/effect-machine-react/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "../../tsconfig.base.json",
+ "include": ["src"],
+ "references": [
+ { "path": "../effect-machine" }
+ ],
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist"
+ }
+}
diff --git a/packages/effect-machine-react/typetest/MachineAtom.tst.ts b/packages/effect-machine-react/typetest/MachineAtom.tst.ts
new file mode 100644
index 0000000..929604e
--- /dev/null
+++ b/packages/effect-machine-react/typetest/MachineAtom.tst.ts
@@ -0,0 +1,26 @@
+import { Schema } from "effect"
+import { expect } from "tstyche"
+import { Machine } from "../../effect-machine/src/index.js"
+import { AtomMachine } from "../../effect-machine/src/unstable/reactivity/index.js"
+import { useMachineAtom } from "../src/index.js"
+
+class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {}
+class Continue extends Schema.TaggedClass("Continue")("Continue", {}) {}
+
+const States = Machine.states({ Idle })
+const machine = Machine.make({
+ states: States.states,
+ events: Machine.events(Continue),
+ initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new Idle({})))
+}).handle({
+ Idle: {
+ on: {
+ Continue: (to) => to.none
+ }
+ }
+})
+const expected = AtomMachine.make(machine)
+
+const owned = useMachineAtom(() => AtomMachine.make(machine))
+
+expect(owned).type.toBe()
diff --git a/packages/effect-machine/README.md b/packages/effect-machine/README.md
index 31c55f3..1a5eb2e 100644
--- a/packages/effect-machine/README.md
+++ b/packages/effect-machine/README.md
@@ -750,13 +750,21 @@ applications. Service-free machines can use `AtomMachine.make(Counter)`.
The bridge exposes `ref`, `snapshot`, `state`, fail-aware `result`, writable
`send` and `stop` atoms, and `child(descriptor)`. Use `AtomMachine.select`,
`AtomMachine.selectSnapshot`, and `AtomMachine.matches` for typed,
-equality-aware derivations. Hooks from `@effect/atom-react` use a shared default
-registry. Add `RegistryProvider` only when a subtree needs separate registry
-identity or disposal.
-
-For a machine with startup input, `AtomMachine.family` uses that input as the
-family key and exposes direct atom families. Each returned atom retains its
-private machine bridge while preserving lazy registry startup and disposal:
+equality-aware derivations. Repeating one of these calls with the same bridge
+and state path returns the same atom.
+
+Use `useMachineAtom` from `@typeonce/effect-machine-react` when one React
+subtree owns the machine. It mounts the machine without subscribing the owner
+to state. Pass the returned machine atom through props or Context, then call
+`useAtomSuspense(AtomMachine.selectSnapshot(machine, path))` in the descendant
+that renders that state slot. Hooks from `@effect/atom-react` use a shared
+default registry. Add `RegistryProvider` only when a subtree needs separate
+registry identity or disposal.
+
+When consumers need keyed lookup for a machine with startup input,
+`AtomMachine.family` uses that input as the family key and exposes direct atom
+families. Each returned atom retains its private machine bridge while
+preserving lazy registry startup and disposal:
```ts
const processAtoms = AtomMachine.bind(runtime).family(processMachine, {
diff --git a/packages/effect-machine/docs/effect-atom-react.md b/packages/effect-machine/docs/effect-atom-react.md
index ad938a2..e9c583e 100644
--- a/packages/effect-machine/docs/effect-atom-react.md
+++ b/packages/effect-machine/docs/effect-atom-react.md
@@ -1,208 +1,293 @@
-# Effect Atom and React patterns
+# Effect Atom and React
-This guide records the folder organization and four integration patterns
-validated in the process app. Use the API reference for individual AtomMachine
-operations. Read the [Effect Machine agent guide](./agent-guide.md) for
-statechart modeling, transitions, services, and testing.
+React code should own one stable machine atom, pass it through props or
+Context, and subscribe in the descendants that render machine state. Keep the
+machine definition free of React dependencies.
+
+Read the [Effect Machine agent guide](./agent-guide.md) for statechart
+modeling, transitions, services, and testing.
## Recommended folder structure
```text
src/
-├── context/ # Optional React Context adapters
-│ ├── dialog-context.tsx
-│ └── process-context.tsx
+├── context/
+│ └── auth-machine-context.tsx # React ownership and distribution
├── lib/
-│ ├── atom-runtime.ts # Shared bound AtomMachine runtime
-│ └── services/ # Generic Effect business services
-│ └── query-processor.ts
+│ ├── atom-runtime.ts # Shared bound AtomMachine runtime
+│ └── services/
└── machines/
- ├── counter/
- │ ├── machine.ts # Machine implementation
- │ └── atom.ts # Focused atoms for React
- ├── process/
- │ ├── machine.ts
- │ └── atom.ts
- └── dialog/
- ├── machine.ts
- └── atom.ts
+ └── auth-machine.ts # States, events, and behavior
```
-Keep these responsibilities separate:
+`machine.ts` owns the workflow. A Context module only creates and distributes
+the machine atom. State-slot components decide which state paths they render.
-- `machine.ts` defines states, events, transitions, statechart behavior, and
- Effect service requirements. It has no React dependency.
-- `atom.ts` adapts that machine to the shared bound AtomMachine runtime and
- exports the focused atoms React needs.
-- `lib/services/` contains reusable business services used by machines.
-- `context/` is optional. It only distributes an already-created machine scope
- through a React subtree.
-- `lib/atom-runtime.ts` binds AtomMachine once to the application's Effect
- service layer:
+Bind service-backed machines once at the application runtime:
```ts
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
import { Atom } from "effect/unstable/reactivity"
-import { QueryProcessor } from "./services/query-processor"
+import { AppLayer } from "./app-layer"
-const atomRuntime = Atom.runtime(QueryProcessor.layer)
+const atomRuntime = Atom.runtime(AppLayer)
-export const machineAtoms = AtomMachine.bind(atomRuntime)
+export const MachineAtoms = AtomMachine.bind(atomRuntime)
```
-Each `machineAtoms.make` call still creates an independent machine bridge.
+Service-free machines can use `AtomMachine.make` directly.
-## 1. One global actor with no input
+## Own a machine in one React subtree
-Use a module-level bridge when a no-input machine intentionally has one
-application-wide instance:
+Use `useMachineAtom` when a provider, route, dialog, or other React subtree
+owns one machine instance:
-```ts
-import { machineAtoms } from "@/lib/atom-runtime"
-import { AtomMachine } from "@typeonce/effect-machine/reactivity"
-import { counterMachine } from "./machine"
+```tsx
+import { useMachineAtom } from "@typeonce/effect-machine-react"
+import { createContext, type ReactNode, useContext } from "react"
+import { AuthMachine, type AuthMachineInput } from "../machines/auth-machine"
+import { MachineAtoms } from "../lib/atom-runtime"
+
+const makeAuthMachine = (input: AuthMachineInput) => MachineAtoms.make(AuthMachine, input)
+type AuthMachineAtom = ReturnType
-export const counterMachineAtom = machineAtoms.make(counterMachine)
+const AuthMachineContext = createContext(null)
+
+export function AuthMachineProvider({
+ children,
+ input
+}: {
+ readonly children: ReactNode
+ readonly input: AuthMachineInput
+}) {
+ const machine = useMachineAtom(() => makeAuthMachine(input))
+
+ return (
+
+ {children}
+
+ )
+}
-export const counterStateAtom = AtomMachine.select(
- counterMachineAtom,
- "counter"
-)
+export function useAuthMachine(): AuthMachineAtom {
+ const machine = useContext(AuthMachineContext)
+ if (machine === null) {
+ throw new Error("useAuthMachine must be used inside AuthMachineProvider")
+ }
+ return machine
+}
```
-"Global" means every import reaches this bridge under the same atom registry.
-Consumers read `counterStateAtom` and use `counterMachineAtom.send` directly.
-Do not add a redundant `counterSendAtom` alias.
+The provider strongly owns the complete `MachineAtom`. The hook mounts
+`machine.ref` after React commits the owner, but it does not read `state`,
+`snapshot`, or `result`. Machine updates therefore do not rerender the
+provider.
-## 2. A keyed machine with startup input
+The factory captures startup input once. A later `input` prop change does not
+replace the running workflow. Send an event when the change belongs to that
+workflow. Change the provider's React key when React should own a new machine:
-`AtomMachine.family` uses the machine input as both startup input and family
-key. It returns one direct atom family for each entry in `atoms`:
+```tsx
+
+
+
+```
-```ts
-import { machineAtoms } from "@/lib/atom-runtime"
+Put the owner above a Suspense boundary. React can then retain the same machine
+while a state-reading descendant suspends.
+
+## Render state-owned data
+
+Subscribe in the smallest component that renders a state path:
+
+```tsx
+import { useAtomSuspense } from "@effect/atom-react"
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
-import { processMachine } from "./machine"
+import { Option } from "effect"
+
+function EditingFields() {
+ const machine = useAuthMachine()
+ const editing = useAtomSuspense(
+ AtomMachine.selectSnapshot(machine, "Editing")
+ ).value
+
+ return Option.match(editing, {
+ onNone: () => null,
+ onSome: ({ value }) =>
+ })
+}
+```
-export const processAtoms = machineAtoms.family(processMachine, {
- atoms: {
- details: AtomMachine.select("process"),
- result: AtomMachine.select("process.Ready"),
- send: (machine) => machine.send
- },
- label: (input, name) => `process:${input.query}:${name}`
-})
+`AtomMachine.select` returns the selected state value.
+`AtomMachine.selectSnapshot` also retains the selected state's child topology.
+Both return `Option.none()` while the path is inactive. Do not replace that
+absence with an empty string, `null`, or a global boolean.
+
+Repeated calls with the same machine and path return the same atom, so path
+selection is safe during render without `useMemo`. Equal selected values do not
+notify the component.
+
+Nested paths keep the same ownership:
+
+```tsx
+function PasswordField() {
+ const machine = useAuthMachine()
+ const password = useAtomSuspense(
+ AtomMachine.select(machine, "Editing.Password")
+ ).value
+
+ return Option.match(password, {
+ onNone: () => null,
+ onSome: ({ password }) =>
+ })
+}
```
-React consumes each projected family directly:
+Place independent subscriptions in independent descendants:
```tsx
-const input = { query }
-const details = useAtomValue(processAtoms.details(input))
-const send = useAtomSet(processAtoms.send(input))
+function AuthCard() {
+ return (
+ <>
+
+
+
+
+ >
+ )
+}
+```
+
+Atom granularity cannot isolate hooks that all live in `AuthCard`. Any selected
+change rerenders the component that called the hook.
+
+## Send without subscribing
+
+Use the writable atom directly:
+
+```tsx
+import { useAtomSet } from "@effect/atom-react"
+
+function SubmitButton() {
+ const machine = useAuthMachine()
+ const send = useAtomSet(machine.send)
+
+ return (
+
+ )
+}
```
-Each public atom retains its private machine bridge. Keeping only `details` or
-only `send` is safe. The bridge still starts lazily in the registry and stops
-when that registry releases or disposes it. A writable source remains writable,
-and a projection keeps the source atom's equality function.
+`useAtomSet` mounts the writable atom and does not subscribe the component to
+its value.
-The family uses Effect `Equal` and `Hash` semantics. Equal records such as
-`{ query: "effect" }` select the same family value even when reconstructed.
-Keep inputs immutable because mutating a hashed key makes later lookup
-unreliable. Different input values select independent machines. If a changing
-value should update one running workflow, model the change as an event instead
-of putting it in the machine input.
+## Whole-result and custom selections
-Service-free machines use the module function directly:
+Reading the full result is correct when a component renders the complete
+machine state:
+
+```tsx
+function AuthScreen() {
+ const machine = useAuthMachine()
+ const state = useAtomSuspense(machine.result).value
+
+ return AuthStates.match(state, {
+ Editing: (editing) => ,
+ Verification: (verification) => ,
+ Failed: (failed) =>
+ })
+}
+```
+
+That component rerenders for every result change. Current
+`@effect/atom-react` does not select from the successful value in
+`useAtomSuspense`. Until it does, use typed path selectors for state-owned UI,
+or declare a custom derived atom once in a strongly owned scope. Do not create
+a fresh derived atom on every render.
+
+## Share a keyed machine outside one React owner
+
+`AtomMachine.family` is for registry-owned machines that unrelated consumers
+find by startup input. It is not the default for one React-owned workflow.
```ts
-export const processAtoms = AtomMachine.family(processMachine, {
+export const processAtoms = MachineAtoms.family(ProcessMachine, {
atoms: {
- details: AtomMachine.select("process"),
+ details: AtomMachine.select("Processing"),
+ ready: AtomMachine.matches("Ready"),
send: (machine) => machine.send
}
})
```
-## 3. Reusing one machine definition for multiple instances
+Consumers use the input as the shared identity key:
+
+```tsx
+const details = useAtomSuspense(processAtoms.details(input)).value
+const send = useAtomSet(processAtoms.send(input))
+```
-Define the dialog adapter once:
+Each public projection retains its private machine owner. Keeping only
+`details(input)` or `send(input)` is safe. Do not return a weakly held composite
+scope and retain only one field from it.
-```ts
-import { machineAtoms } from "@/lib/atom-runtime"
-import { AtomMachine } from "@typeonce/effect-machine/reactivity"
-import { dialogMachine } from "./machine"
+Family keys use Effect `Equal` and `Hash` semantics. Keep them immutable. If a
+changing value should update one running workflow, model it as an event instead
+of changing the family key.
-export function makeDialogScope() {
- const machine = machineAtoms.make(dialogMachine)
+## Module-owned machines
- return {
- isOpenAtom: AtomMachine.matches(machine, "Open"),
- isClosedAtom: AtomMachine.matches(machine, "Closed"),
- openStateAtom: AtomMachine.select(machine, "Open"),
- sendAtom: machine.send
- }
-}
+A no-input machine may intentionally have one module-owned identity:
-export type DialogScope = ReturnType
+```ts
+export const CounterMachineAtom = MachineAtoms.make(CounterMachine)
+export const CounterStateAtom = AtomMachine.select(CounterMachineAtom, "Count")
```
-### React-tree-owned instance
+Every consumer using the same `AtomRegistry` reaches the same running machine.
+Different registries still run independent instances.
-```tsx
-const DialogContext = createContext(null)
+## Child machines
-export function DialogProvider({ children }: { children: ReactNode }) {
- const [scope] = useState(makeDialogScope)
+Direct child selectors follow the active child and preserve inactivity:
- return (
-
- {children}
-
- )
-}
+```tsx
+const editor = machine.child(Editor)
+const editing = useAtomSuspense(
+ AtomMachine.selectSnapshotChild(editor, "Editing")
+).value
```
-Each provider owns one independent dialog. Descendants use a small
-`useDialog()` hook and subscribe to the focused atom they need. Pass
-`DialogScope` through props when Context is unnecessary. Do not add a wrapper
-component whose only job is forwarding the scope.
-
-For a no-input machine, use one module-level bridge or an explicitly owned
-React scope. Do not add a family key that the machine does not consume. When an
-ID is part of startup semantics, declare it in the machine input and use
-`AtomMachine.family`.
+An inactive child or path returns `Option.none()`. Re-entry follows the
+replacement child instance.
-## 4. Selecting process-owned child machines
-
-Bind a machine definition once when a parent owns a runtime-sized set of child
-machines:
+Use `AtomMachine.familyChild` when a parent owns a runtime-sized set of keyed
+children:
```ts
-const Plant = Machine.childFamily(plantMachine)
-
-export const centralMachineAtom = machineAtoms.make(centralMachine)
+const Plant = Machine.childFamily(PlantMachine)
-export const plantAtoms = AtomMachine.familyChild(centralMachineAtom, {
+export const plantAtoms = AtomMachine.familyChild(CentralMachineAtom, {
child: (plantId: string) => Plant(plantId),
atoms: {
+ broken: AtomMachine.matchesChild("Broken"),
state: (plant) => plant.state,
- isBroken: AtomMachine.matchesChild("Broken"),
- send: (plant) => plant.send,
- stop: (plant) => plant.stop
+ send: (plant) => plant.send
}
})
-
-const broken = useAtomValue(plantAtoms.isBroken(plantId))
-const send = useAtomSet(plantAtoms.send(plantId))
```
-`familyChild` keeps child lookup separate from root machine startup. Each
-projected atom retains the child bridge returned for its key.
+## Registry and rendering semantics
+
+A `MachineAtom` identifies one machine per `AtomRegistry`. Passing the same
+machine atom through two registry providers creates two independent runtimes.
+Unmounting a React owner releases its mount. The registry stops the machine
+after its final subscription and configured idle retention expire.
+`registry.dispose()` stops it immediately.
-`Plant(plantId)` may be reconstructed wherever the id is available. Child
-lookup and bridge reuse match by machine identity and id, not descriptor object
-identity. Before the parent spawns that child, selectors contain `Option.none`
-and `matchesChild` is `false`. They follow the child after startup and return to
-the inactive values after it stops.
+`useMachineAtom` does not start a machine during server rendering because
+React effects do not run on the server. Reading a machine atom during server
+render follows `@effect/atom-react` server-read behavior, so choose an explicit
+client boundary when server startup would be undesirable.
diff --git a/packages/effect-machine/docs/machine-review.md b/packages/effect-machine/docs/machine-review.md
index e1e781d..4078151 100644
--- a/packages/effect-machine/docs/machine-review.md
+++ b/packages/effect-machine/docs/machine-review.md
@@ -65,11 +65,24 @@ const handlers = {
Review check: search for `.resolve(...)` callbacks that only return an empty
`target.from()` and remove the callback.
-## Use retained families for keyed machine input
+## Choose React ownership or keyed family lookup
-Effect Atom keeps a family value for an equal key while that returned value is
-reachable. Current runtimes may hold family values through `WeakRef`. Retaining
-one field from a composite family value does not retain the composite itself:
+Use `useMachineAtom` when one React subtree owns the workflow, including a
+machine with startup input:
+
+```tsx
+const machine = useMachineAtom(() => machineAtoms.make(processMachine, input))
+```
+
+Pass the stable machine through props or Context. Startup input is captured
+once. Send an event to change the running workflow, or change the owner's React
+key to replace it.
+
+Use `AtomMachine.family` when unrelated consumers must find one shared machine
+by its startup input. Effect Atom keeps a family value for an equal key while
+that returned value is reachable. Current runtimes may hold family values
+through `WeakRef`. Retaining one field from a composite family value does not
+retain the composite itself:
```ts
// Unsafe when consumers retain only stateAtom or sendAtom
@@ -82,8 +95,8 @@ const processScope = Atom.family((input: ProcessInput) => {
})
```
-Use `AtomMachine.family` for an input-bearing machine. It returns direct atom
-families whose atoms retain the private machine bridge:
+`AtomMachine.family` returns direct atom families whose atoms retain the
+private machine bridge:
```ts
export const processAtoms = machineAtoms.family(processMachine, {
@@ -101,14 +114,14 @@ No component `useMemo` is needed. The registry retains the public atom while a
hook subscribes to it, and that atom retains the machine owner. Equal inputs
use Effect `Equal` and `Hash` semantics and select the same family value.
-For a no-input machine, use one module-level bridge or a lazy
-`useState(makeScope)` value owned by a React subtree. Do not add an unused key.
+For a no-input machine, use one module-level bridge or `useMachineAtom` in the
+owning React subtree. Do not add an unused family key.
Review check: search for composite `Atom.family` values that own a machine,
-`useMemo` around family lookup, and component-local calls to
-`machineAtoms.make`. Replace an input-bearing machine with
-`AtomMachine.family`. Give a no-input instance an explicit module or React-tree
-owner.
+`useMemo` around family lookup, repeated input propagation through one React
+subtree, and component-local calls to `machineAtoms.make` without a stable
+owner. Choose `AtomMachine.family` only when consumers need shared keyed
+lookup.
## Justify each `RegistryProvider`
diff --git a/packages/effect-machine/src/internal/machine/atom.ts b/packages/effect-machine/src/internal/machine/atom.ts
index f85ecba..0572da8 100644
--- a/packages/effect-machine/src/internal/machine/atom.ts
+++ b/packages/effect-machine/src/internal/machine/atom.ts
@@ -557,6 +557,41 @@ const selectSnapshotByPath = <
): Option.Option> =>
Topology.getSnapshotByPath(snapshot, path) as Option.Option>
+type SelectorKind =
+ | "matches"
+ | "matchesChild"
+ | "select"
+ | "selectChild"
+ | "selectSnapshot"
+ | "selectSnapshotChild"
+
+const selectorsByBridge = new WeakMap