Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/retained-atom-machine-families.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@typeonce/effect-machine": minor
---

Add `AtomMachine.family` and `AtomMachine.familyChild` for keyed machine atoms that retain their machine bridge and use weak family values when the runtime supports them.

The machine startup input is the root family key. Define each public readonly or writable atom once, then look it up directly from React without `useMemo`:

```ts
const processAtoms = AtomMachine.family(processMachine, {
atoms: {
details: AtomMachine.select("Processing"),
send: (machine) => machine.send
}
})
```

Root and child selectors now also support data-last calls such as `AtomMachine.select("Processing")(machine)`.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"packageManager": "pnpm@10.17.1",
"scripts": {
"build": "pnpm --recursive --filter \"./packages/**\" run build",
"test": "vitest run",
"test": "node --expose-gc ./node_modules/vitest/vitest.mjs run",
"test:types": "pnpm --dir packages/effect-machine test:types",
"devtools": "pnpm --dir packages/devtools dev",
"visualizer": "pnpm devtools",
Expand Down
37 changes: 33 additions & 4 deletions packages/effect-machine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -750,16 +750,45 @@ 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. React applications using `@effect/atom-react` need
a `RegistryProvider`.
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:

```ts
const processAtoms = AtomMachine.bind(runtime).family(processMachine, {
atoms: {
details: AtomMachine.select("Processing"),
ready: AtomMachine.matches("Ready"),
send: (machine) => machine.send
}
})

const detailsAtom = processAtoms.details(input)
const sendAtom = processAtoms.send(input)
```

The key follows Effect `Equal` and `Hash` semantics. Equal input values reuse
the same public atom while it remains reachable. The family does not keep an
unbounded strong cache on platforms with weak references. Keep keys immutable.

Descriptors reconstructed from a `Machine.childFamily` resolve the same child
bridge by machine identity and id:

```ts
const Plant = Machine.childFamily(plantMachine)
const plantAtom = centralAtom.child(Plant(selectedPlantId))
const brokenAtom = AtomMachine.matchesChild(plantAtom, "Broken")
const plants = AtomMachine.familyChild(centralAtom, {
child: (plantId: string) => Plant(plantId),
atoms: {
broken: AtomMachine.matchesChild("Broken"),
send: (plant) => plant.send
}
})

const brokenAtom = plants.broken(selectedPlantId)
```

Emissions stay streams rather than becoming retained atom state:
Expand Down
136 changes: 57 additions & 79 deletions packages/effect-machine/docs/effect-atom-react.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Effect Atom and React patterns

This guide records the folder organization and three integration patterns
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.
Expand Down Expand Up @@ -74,35 +74,56 @@ export const counterStateAtom = AtomMachine.select(
Consumers read `counterStateAtom` and use `counterMachineAtom.send` directly.
Do not add a redundant `counterSendAtom` alias.

## 2. A machine with startup input selected through a family
## 2. A keyed machine with startup input

Use the family key as machine identity. The same value can also be startup
input:
`AtomMachine.family` uses the machine input as both startup input and family
key. It returns one direct atom family for each entry in `atoms`:

```ts
import { machineAtoms } from "@/lib/atom-runtime"
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
import { Atom } from "effect/unstable/reactivity"
import { processMachine } from "./machine"

export const processFamily = Atom.family((query: string) => {
const machine = machineAtoms.make(processMachine, { query })

return {
detailsAtom: AtomMachine.select(machine, "process"),
resultAtom: AtomMachine.select(machine, "process.Ready"),
sendAtom: machine.send
}
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}`
})
```

A consumer calls `processFamily(query)` and uses the returned focused atoms. If
several nested components need the same scope, an optional Context can expose
`ReturnType<typeof processFamily>`. The provider resolves the query once instead
of drilling it through every component.
React consumes each projected family directly:

```tsx
const input = { query }
const details = useAtomValue(processAtoms.details(input))
const send = useAtomSet(processAtoms.send(input))
```

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.

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.

Changing `query` selects another family member and therefore another machine.
If a changing value should update the current workflow, model it as an event.
Service-free machines use the module function directly:

```ts
export const processAtoms = AtomMachine.family(processMachine, {
atoms: {
details: AtomMachine.select("process"),
send: (machine) => machine.send
}
})
```

## 3. Reusing one machine definition for multiple instances

Expand All @@ -127,8 +148,6 @@ export function makeDialogScope() {
export type DialogScope = ReturnType<typeof makeDialogScope>
```

Choose one of the following ownership forms.

### React-tree-owned instance

```tsx
Expand All @@ -150,56 +169,10 @@ Each provider owns one independent dialog. Descendants use a small
`DialogScope` through props when Context is unnecessary. Do not add a wrapper
component whose only job is forwarding the scope.

### Stable keyed instances shared across scattered components

Use one private family to create the scope for an ID. Public selector families
reach that same scope:

```ts
import { Atom } from "effect/unstable/reactivity"

const dialogScopeFamily = Atom.family((dialogId: string) => {
const scope = makeDialogScope()

return {
isOpenAtom: scope.isOpenAtom.pipe(
Atom.withLabel(`dialog:${dialogId}:isOpen-source`)
),
openStateAtom: scope.openStateAtom.pipe(
Atom.withLabel(`dialog:${dialogId}:openState-source`)
),
sendAtom: scope.sendAtom.pipe(
Atom.withLabel(`dialog:${dialogId}:send-source`)
)
}
})

export const dialogIsOpenFamily = Atom.family((dialogId: string) => {
const scope = dialogScopeFamily(dialogId)

return Atom.transform(
scope.sendAtom,
(get) => get(scope.isOpenAtom)
).pipe(Atom.withLabel(`dialog:${dialogId}:isOpen`))
})

export const dialogOpenStateFamily = Atom.family((dialogId: string) => {
const scope = dialogScopeFamily(dialogId)

return Atom.transform(
scope.sendAtom,
(get) => get(scope.openStateAtom)
).pipe(Atom.withLabel(`dialog:${dialogId}:openState`))
})
```

Components using the same `dialogId` share one machine. Different IDs create
independent machines. The two public projections let consumers subscribe
independently. Both remain writable through `Atom.transform`, so either can send
the inferred dialog events.

Use `dialogId` in atom labels for diagnostics. Do not pass it into
`dialogMachine` as unused fake input.
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`.

## 4. Selecting process-owned child machines

Expand All @@ -211,18 +184,23 @@ const Plant = Machine.childFamily(plantMachine)

export const centralMachineAtom = machineAtoms.make(centralMachine)

export const plantScopeFamily = Atom.family((plantId: string) => {
const plant = centralMachineAtom.child(Plant(plantId))

return {
stateAtom: plant.state,
isBrokenAtom: AtomMachine.matchesChild(plant, "Broken"),
sendAtom: plant.send,
stopAtom: plant.stop
export const plantAtoms = AtomMachine.familyChild(centralMachineAtom, {
child: (plantId: string) => Plant(plantId),
atoms: {
state: (plant) => plant.state,
isBroken: AtomMachine.matchesChild("Broken"),
send: (plant) => plant.send,
stop: (plant) => plant.stop
}
})

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.

`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`
Expand Down
59 changes: 32 additions & 27 deletions packages/effect-machine/docs/machine-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,45 +65,50 @@ const handlers = {
Review check: search for `.resolve(...)` callbacks that only return an empty
`target.from()` and remove the callback.

## Let `Atom.family` own keyed identity
## Use retained families for keyed machine input

Treat `useMemo` around an atom family lookup as a warning sign. `Atom.family`
already returns the same retained object for the same key, including when
separate components perform the lookup.

```tsx
// Redundant and local to one component
const scope = useMemo(() => processFamily(processId), [processId])

// The family owns identity
const scope = processFamily(processId)
```

If the component constructs the atoms or machine scope directly, move that
construction into a module-level family:
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
export const processFamily = Atom.family((processId: string) => {
const machine = machineAtoms.make(processMachine, { processId })

// Unsafe when consumers retain only stateAtom or sendAtom
const processScope = Atom.family((input: ProcessInput) => {
const machine = machineAtoms.make(processMachine, input)
return {
stateAtom: AtomMachine.select(machine, "process"),
sendAtom: machine.send
}
})
```

Use a stable domain key. A new key means a different machine instance. Send an
event when a value should update the current workflow instead.
Use `AtomMachine.family` for an input-bearing machine. It returns direct atom
families whose atoms retain the private machine bridge:

```ts
export const processAtoms = machineAtoms.family(processMachine, {
atoms: {
state: AtomMachine.select("process"),
send: (machine) => machine.send
}
})

const stateAtom = processAtoms.state(input)
const sendAtom = processAtoms.send(input)
```

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.

`useMemo` may still be useful for unrelated expensive calculations. It should
not establish atom or machine identity. For one instance owned only by a React
subtree, use a lazy `useState(makeScope)` initializer as described in the React
guide.
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.

Review check: search for `useMemo` around atom creation, family lookup, or
`machineAtoms.make`. Replace component-local identity with `Atom.family`, or
with an intentional component-owned scope.
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.

## Justify each `RegistryProvider`

Expand Down
Loading