Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
987a9be
init
alexcarpenter Jun 18, 2026
803ea09
wip
alexcarpenter Jun 18, 2026
b5b9b37
Update README.md
alexcarpenter Jun 18, 2026
40913f4
Create ADOPTION.md
alexcarpenter Jun 18, 2026
2c0cbc6
feat(ui): harden Mosaic machine type safety and migrate org sections
alexcarpenter Jun 18, 2026
76df631
add useMachineLogger
alexcarpenter Jun 18, 2026
9ca083c
fix(ui): restart actor after stop so send works through StrictMode re…
alexcarpenter Jun 18, 2026
1f7ff0b
fix(ui): reset actor to initial state on restart to prevent invoke re…
alexcarpenter Jun 18, 2026
15788ac
fix(ui): use ref wrapper to prevent stale closure in section machine …
alexcarpenter Jun 18, 2026
8245c15
feat(ui): add actor.setContext and sync options.context via useLayout…
alexcarpenter Jun 18, 2026
a521f2e
add changeset
alexcarpenter Jun 18, 2026
0266948
Revert "add changeset"
alexcarpenter Jun 18, 2026
0ab48ad
feat(ui): add TStates generic and constrain on keys to TEvent['type']
alexcarpenter Jun 18, 2026
7663f1a
Create SKILL.md
alexcarpenter Jun 18, 2026
99c8634
feat(ui): add onDone callback to useMachine
alexcarpenter Jun 19, 2026
dc92867
Update leave-organization.tsx
alexcarpenter Jun 19, 2026
ffc1130
feat(ui): add waitFor utility to mosaic machine
alexcarpenter Jun 19, 2026
cfe984c
feat(ui): add after (delayed transitions) to mosaic state machine
alexcarpenter Jun 19, 2026
72bb8b5
chore(ui): remove waitFor — no production usage yet
alexcarpenter Jun 19, 2026
e1c94a8
wip
alexcarpenter Jun 19, 2026
bf5d62f
feat(ui): add fromPromise to setup() for typed invoke output
alexcarpenter Jun 19, 2026
6741647
docs(ui): document useEffect → machine migration patterns
alexcarpenter Jun 19, 2026
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-state-machine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
218 changes: 218 additions & 0 deletions .claude/skills/mosaic-machine/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
---
name: mosaic-machine
description: >
Author and use Mosaic state machines. Use when the user is writing a state machine
with createMachine, modelling a multi-step flow, wiring a machine to React with
useMachine/useActor/useSelector, debugging a machine transition, or migrating from
useState booleans to a machine.
---

# Mosaic Machine

> **XState-first rule:** Before designing any library feature or changing any API, look up how XState v5 handles the same pattern and align to it. Never invent new API shapes.

Core imports live in `packages/ui/src/mosaic/machine/`.

```ts
import { setup } from './setup'; // primary: pre-binds TContext + TEvent
import { createActor, mockActor } from './createActor';
import { useMachine, useActor, useSelector } from './useMachine';

// Lower-level (only when not using setup):
import { createMachine } from './createMachine';
import { assign } from './assign';
```

`setup<TContext, TEvent>()` returns `{ createMachine, assign, fromPromise }`. Use `fromPromise` for all `invoke` configurations — it carries the resolved type to `e.output` in `onDone.actions`.

---

## Anatomy

Use `setup<TContext, TEvent>()` at the top of each machine file. It pre-binds
both type parameters, returning a typed `createMachine` and `assign` so you
never have to restate them at call sites.

```ts
import { setup } from './setup';

// 1. Define context type — flat object, null defaults for optional fields.
interface MyContext {
data: string | null;
error: string | null;
}

// 2. Define the event union — SCREAMING_SNAKE_CASE types.
type MyEvent = { type: 'FETCH' } | { type: 'RETRY' } | { type: 'RESET' };

// 3. Pre-bind types once for the file.
const { createMachine, assign, fromPromise } = setup<MyContext, MyEvent>();

// 4. Factory when async deps are needed; plain createMachine() when not.
export function createMyMachine(fetchData: () => Promise<string>) {
return createMachine({
// no <MyContext, MyEvent> needed
id: 'my',
initial: 'idle',
context: { data: null, error: null },
states: {
idle: {
on: { FETCH: 'loading' },
},
loading: {
// fromPromise carries the resolved type to e.output in onDone.actions.
// A raw src function also works — e.output is `any` in that case.
invoke: fromPromise(() => fetchData(), {
onDone: {
target: 'success',
// e.output: string — typed from fetchData's return type, no cast needed
actions: assign((_, e) => ({ data: e.output, error: null })),
},
onError: {
target: 'failure',
// e: ErrorInvokeEvent — inferred, no import needed
actions: assign((_, e) => ({ error: String(e.error) })),
},
}),
},
success: { type: 'final' },
failure: {
on: { RETRY: 'loading', RESET: 'idle' },
},
},
});
}
```

`assign`'s second type parameter is inferred from its position:

- Inside `on['SOME_EVENT']` → narrowed to that event member (e.g. `e.value` is safe)
- Inside `fromPromise(...).onDone` → `DoneInvokeEvent<TOutput>` where `TOutput` is the src return type
- Inside `onError` → `ErrorInvokeEvent`
- Inside `after[delay]` → `AfterEvent`

You do **not** need to import or write `DoneInvokeEvent`, `ErrorInvokeEvent`, `AfterEvent`,
or `Extract<Event, { type: 'X' }>` in machine files.

---

## Do's

**Model states, not booleans.** Replace `isOpen + isDeleting + isError` with explicit states — `idle → confirming → deleting → deleted`. Impossible combinations become unrepresentable.

**Define machines at module level or in a factory function.** They're static descriptions; creating inside a component recreates the object on every render (harmless for `useMachine` due to its `useRef` guard, but confusing and wasteful).

**Inject async deps via a factory, not module-level closure.**

```ts
// ✓ factory — testable, no import-time side effects
export const createDeleteOrgMachine = (destroyFn: () => Promise<void>) => createMachine({ ... });

// ✗ module-level capture — hard to test, couples to module load order
const machine = createMachine({ states: { deleting: { invoke: { src: () => someGlobal.destroy() } } } });
```

**Use `assign` for context updates.** It's a pure `(context, event) => Partial<context>` — the runtime merges the patch.

**Use `invoke` for async work.** Actions are synchronous side effects only; promises in actions are invisible to the machine.

**Gate navigation with state-node `guard`.** Every transition targeting the state checks it automatically — no per-transition boilerplate.

```ts
states: {
step2: {
guard: (ctx) => ctx.step1Complete, // blocks all entry to step2
on: { NEXT: 'step3', PREV: 'step1' },
},
}
```

**Test in plain JS.** Drive `createActor → start → send` with no React. Reach unreachable/transient states with `mockActor`:

```ts
const actor = mockActor(machine, { value: 'deleting', context: { error: null } });
expect(actor.getSnapshot().value).toBe('deleting');
```

**Use `actor.recheck()` when external data a guard reads changes.** It re-seats to the derived initial if the current state's guard no longer holds, or fires any pending `always` transition.

---

## Don'ts

**Don't do async work in `actions`.** Promises returned from an action function are dropped — the machine never sees the resolved value.

**Don't mutate context directly in actions.** Side effects only; use `assign` to update context.

**Don't track "impossible" state in context.** If you find yourself checking `isDeleting && isOpen`, add a state instead of adding a guard on a context flag.

**Don't pass an async function captured at module definition time.** It can't be stubbed in tests, and it breaks the pattern of injecting live props.

---

## React patterns

### `useMachine` — own a flow for the component's lifetime

```tsx
function DeleteOrganization({ organization }: { organization: Org }) {
const [snapshot, send] = useMachine(deleteOrgMachine, {
// `context` is kept current via useLayoutEffect — safe to pass live props/functions.
context: { destroyFn: () => organization.destroy() },
// `onDone` fires once when the machine reaches a `type: 'final'` state.
onDone: () => router.navigate('/dashboard'),
});

return (
<ConfirmDialog
open={snapshot.value === 'confirming' || snapshot.value === 'deleting'}
onOpenChange={isOpen => send({ type: isOpen ? 'OPEN' : 'CANCEL' })}
isDeleting={snapshot.value === 'deleting'}
onConfirm={() => send({ type: 'CONFIRM' })}
error={snapshot.context.error}
/>
);
}
```

Branch on `snapshot.value` for UI, not on `snapshot.context` booleans.

`onDone` always calls the latest prop — no stale-closure risk. Do not replace it with a `useEffect` watching `snapshot.status`.

### `useActor` — bind to a shared actor

Use when the actor's lifecycle is owned by a parent or context provider.

```tsx
function StepIndicator({ actor }: { actor: WizardActor }) {
const [snapshot] = useActor(actor);
return <Breadcrumb currentStep={snapshot.value} />;
}
```

### `useSelector` — subscribe to a slice

Re-renders only when the selected value changes (by `Object.is`). Primary way to consume a shared actor without full-snapshot coupling.

```tsx
const error = useSelector(actor, snap => snap.context.error);
const isDeleting = useSelector(actor, snap => snap.value === 'deleting');
```

### Injecting live props

`useMachine` calls `actor.setContext(options.context)` via `useLayoutEffect` after every render. Pass functions from props without recreating the machine:

```tsx
// The machine reads `ctx.onSuccess` — always the latest prop.
const [snapshot, send] = useMachine(machine, { context: { onSuccess: props.onSuccess } });
```

### Debug logging (remove before shipping)

```tsx
import { useMachineLogger } from './useMachine';

const [snapshot, send] = useMachine(machine);
useMachineLogger('myFlow', snapshot); // logs: [myFlow] idle → loading { data: null }
```
Loading
Loading