Skip to content

Commit 6e3497b

Browse files
committed
feat(actor): add dispatchPromise for non-Effect boundaries
Promise-based dispatch for React event handlers, framework hooks, and tests that operate outside the Effect runtime. - Add dispatchPromise to ActorRef interface and implementation - 2 new tests: matching and non-matching via Promise API - Add changeset for minor release
1 parent 4d0387e commit 6e3497b

3 files changed

Lines changed: 50 additions & 0 deletions

File tree

.changeset/dispatch-api.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"effect-machine": minor
3+
---
4+
5+
Add `dispatch` and `dispatchPromise` to ActorRef — synchronous event processing with transition receipts.
6+
7+
**`dispatch(event)`** — Effect-based. Sends event through the queue (preserving serialization) and returns `ProcessEventResult<State>` with `{ transitioned, previousState, newState, lifecycleRan, isFinal }`. OTP `gen_server:call` equivalent.
8+
9+
**`dispatchPromise(event)`** — Promise-based. Same semantics as `dispatch` for use at non-Effect boundaries (React event handlers, framework hooks, tests).
10+
11+
Also exports `ProcessEventResult` from the public API.

src/actor.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ export interface ActorRef<State extends { readonly _tag: string }, Event> {
181181
*/
182182
readonly dispatch: (event: Event) => Effect.Effect<ProcessEventResult<State>>;
183183

184+
/**
185+
* Promise-based dispatch — send event and get back the transition result.
186+
* Use at non-Effect boundaries (React event handlers, framework hooks, tests).
187+
*/
188+
readonly dispatchPromise: (event: Event) => Promise<ProcessEventResult<State>>;
189+
184190
/**
185191
* Subscribe to state changes (sync callback)
186192
* Returns unsubscribe function
@@ -549,6 +555,7 @@ export const buildActorRefCore = <
549555
}
550556
},
551557
dispatch,
558+
dispatchPromise: (event) => Effect.runPromise(dispatch(event)),
552559
subscribe: (fn) => {
553560
listeners.add(fn);
554561
return () => {

test/dispatch.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,35 @@ describe("ActorRef.dispatch", () => {
166166
}),
167167
);
168168
});
169+
170+
describe("ActorRef.dispatchPromise", () => {
171+
it.scopedLive("returns ProcessEventResult as Promise", () =>
172+
Effect.gen(function* () {
173+
const machine = createMachine();
174+
const actor = yield* Machine.spawn(machine);
175+
176+
const result = yield* Effect.promise(() =>
177+
actor.dispatchPromise(TestEvent.Start({ value: 99 })),
178+
);
179+
180+
expect(result.transitioned).toBe(true);
181+
expect(result.previousState._tag).toBe("Idle");
182+
expect(result.newState._tag).toBe("Active");
183+
if (result.newState._tag === "Active") {
184+
expect(result.newState.value).toBe(99);
185+
}
186+
}),
187+
);
188+
189+
it.scopedLive("non-matching event returns transitioned false", () =>
190+
Effect.gen(function* () {
191+
const machine = createMachine();
192+
const actor = yield* Machine.spawn(machine);
193+
194+
const result = yield* Effect.promise(() => actor.dispatchPromise(TestEvent.Unknown));
195+
196+
expect(result.transitioned).toBe(false);
197+
expect(result.newState._tag).toBe("Idle");
198+
}),
199+
);
200+
});

0 commit comments

Comments
 (0)