service.send will accept a string followed by an object payload. (https://xstate.js.org/docs/guides/interpretation.html#sending-events)
But when invoking callbacks, the callback argument (which sends an event to the parent) does not. (https://xstate.js.org/docs/guides/communication.html#invoking-callbacks)
Any reason for this discrepancy? I've tripped over this a few times now.
I don't think there is any particular reason for this discrepancy. I think that we should just unify both in the upcoming v5 and drop support for service.send(type, payload). That would also match send action which doesn't support this (type, payload) thing. @davidkpiano WDYT?
For V5, here's what we should do:
service.send(string)actor.send(string) (can really be actor.send(any) - implementation details should not be mandated by XState)~service.send(string, payload)send(string, payload) to:~@xstate/react~@xstate/vue~eventFrom helper function that allows this syntaxservice.send(object) and actor.send(object) of course~So send(type, payload) should be kept at the "leaf-node" integrations (@xstate/react, @xstate/vue, etc.) but removed from core.~
A little sad to lose send(type, payload) from the interpreter — imho, it's a very intuitive expansion of the send(string) form. That said, a consistent, simpler API for core probably makes the most sense.
➕ Allow actor.send(string) (can really be actor.send(any) - implementation details should not be mandated by XState)
We already use a certain "protocol" for sent events (that it is an object with a type property) and we send such to actors. IMHO it's a good thing to enforce this simple (and familiar to most) protocol as it simplifies the implementation in most cases and makes things more predictable by removing the need for defensive code. Seems like a good common denominator that should/could be enforced. If one really, really needs to send thing of a different shape they can always implement deserialization logic on the receiving end of things.
So send(type, payload) should be kept at the "leaf-node" integrations (@xstate/react, @xstate/vue, etc.) but removed from core.
Wouldnt this bring confuse people that it's allowed by the "integrations" and not by the core? What is the benefit of accepting those by the integrations?
Wouldnt this bring confuse people that it's allowed by the "integrations" and not by the core? What is the benefit of accepting those by the integrations?
It will not and it has not (or at least I haven't heard anything about it yet). It's a developer experience item, influenced by Vuex: https://vuex.vuejs.org/guide/actions.html#dispatching-actions
If you feel strongly about removing it, we can ask the community what they think, but I feel like it's a harmless and useful addition.
@akbr A simple change can be made to allow passing strings to callback(stringType), which will be equivalent to callback({ type: stringType })
Just a note - I was strictly advocating for removing send(type, payload), not a simple send(type) form, and was arguing against supporting send(any) in actors.
It will not and it has not (or at least I haven't heard anything about it yet). It's a developer experience item, influenced by Vuex: https://vuex.vuejs.org/guide/actions.html#dispatching-actions
IMHO it's (send(type, payload)) just yet another way of doing the same thing (the third one), which for me seems like too much - such things often only bring inconsistencies across codebases and needless discussions in teams about which one is preferred etc. Personally I also don't see any particular advantage in this API (besides maybe some minor visual appeal) - it's disconnected from declared TS event types and requires TS-shenanigans to be typed correctly, something like:
send<Type extends TEvent["type"]>(
type: Type,
payload: Omit<Extract<TEvent, { type: Type }>, 'type'>
): void;
which is rather far from "easy".
I suppose a good compromise would be a helper function:
import { eventFrom } from 'xstate/event';
// ...
send(eventFrom('CHANGE', { field: 'name', value: 'alice' }));
EDIT: modified my comment above
I want to drop into this thread an example of something that makes send('EVENT', { data }) a useful construct.
My goal is to be able to have semi-dynamic dispatch. You know where you are, and where you need to go, but the receiving state needs additional information (known statically in advance) in order to continue. I don't see it to be remarkably different from done data for invoked state machines. I also believe that this is a generally useful tool for providing backpressure/queuing behavior within the state machine itself.
I've read into the 4.X code to figure out how to insert this behavior. The most-relevant piece is this:
sendWithPayload(
'ALERT',
{
data: {
prompt: 'This window has timed out. What would you like to do?',
answers: [
{ label: 'Keep Waiting', action: 'WAIT' },
{ label: 'Try Again', action: 'RETRY' },
{ label: 'Give Up', action: 'ABORT' }
]
}
}
)
This traverses up through the state machine, and then back down into a singular, modal, alert component. (Think alert, prompt, and confirm.)
A full version is below.
Machine Definition
import { actions, assign, spawn, Machine, interpret, send, sendParent } from 'xstate';
// Piggyback on the send action creator, mimic the actual behavior behind the scenes.
function sendWithPayload(action, payload) {
var temp = send(action);
payload.type = action;
Object.assign(temp.event, payload);
return temp;
}
// This is a window manager. Multiple windows (child components).
function applicationConfigGen() {
return {
id: 'application',
type: 'parallel',
states: {
// This thing can timeout, necessitating an alert.
'child-component1': childComponentConfigGen(),
// This is a singleton across the entire window manager.
'alert': alertComponentConfigGen()
},
on: {
// This is far-more-clear to me than '*' with dispatch based upon `JSON.parse(the event name)`.
// This thing doesn't actually care about the payload, it just needs to direct it
// to the correct place.
'ALERT': {
actions: [
actions.pure((context, event) => { return sendWithPayload('FOREGROUND', event)})
]
}
}
};
}
// This is a singleton. You can only have one alert at a time.
// This component is not, however, just an "OK" type of alert.
// It is designed to receive a configuration that would inform
// what content to display.
function alertComponentConfigGen() {
return {
id: 'alert-component',
initial: 'background',
states: {
// NOTE: this doesn't currently handle back-pressure or modality.
'background': {
on: {
'FOREGROUND': 'foregrounding'
}
},
'foregrounding': {
invoke: {
id: 'foregrounding-promise',
src: Machine(promiseConfigGen()),
onDone: 'foreground'
}
},
'foreground': {
on: {
'BACKGROUND': 'backgrounding'
}
},
'backgrounding': {
invoke: {
id: 'backgrounding-promise',
src: Machine(promiseConfigGen()),
onDone: 'background'
}
}
}
};
}
function childComponentConfigGen() {
return {
id: 'child-component',
initial: 'load',
states: {
'load': {
invoke: {
src: Machine(cancellablePromiseConfigGen()),
onDone: [
{
target: 'resolve',
cond: (context, event) => {
return event.data && event.data.target === 'resolve';
}
},
{
target: 'reject',
cond: (context, event) => {
return event.data && event.data.target === 'reject';
}
}
]
},
on: {
'TIMEOUT': 'timeout'
}
},
'timeout': {
entry: [
// Magic is here. We need an alert!
// The point of invocation knows where it is, and what can be done in this state.
sendWithPayload(
'ALERT',
{
data: {
prompt: 'This window has timed out. What would you like to do?',
answers: [
{ label: 'Keep Waiting', action: 'WAIT' },
{ label: 'Try Again', action: 'RETRY' },
{ label: 'Give Up', action: 'ABORT' }
]
}
}
)
]
},
'resolve': { type: 'final' },
'reject': { type: 'final' }
}
};
}
function cancellablePromiseConfigGen() {
return {
id: 'cancellable-promise',
initial: 'spawn',
context: {},
states: {
'reset': {
always: [
{
target: 'spawn',
actions: [
actions.pure((context) => { return actions.stop(context.promise.id); })
]
}
]
},
'spawn': {
always: [
{
target: 'waiting',
actions: [
assign({
promise: () => {
return spawn(Machine(promiseConfigGen()), { sync: true });
}
})
]
}
]
},
'waiting': {
after: {
1000: 'timeout'
},
on: {
'TIMEOUT': 'timeout'
}
},
'timeout': {
entry: [
sendParent('TIMEOUT')
],
on: {
ABORT: 'abort',
RETRY: { target: 'reset' },
WAIT: 'waiting'
}
},
'abort': { type: 'final', data: { target: 'abort' } },
'resolve': { type: 'final', data: { target: 'resolve' } },
'reject': { type: 'final', data: { target: 'reject' } }
},
on: {
'xstate.update': [
{
target: 'resolve',
cond: (context, event) => {
return event.state.done && event.state.value === 'resolve';
}
},
{
target: 'reject',
cond: (context, event) => {
return event.state.done && event.state.value === 'reject';
}
}
]
}
};
}
function promiseConfigGen() {
return {
id: 'invocable-promise',
initial: 'promise',
states: {
'promise': {
invoke: {
src: () =>
new Promise(function (resolve, reject) {
setTimeout(resolve, 2000);
}),
onDone: 'resolve',
onError: 'reject'
}
},
'resolve': { type: 'final' },
'reject': { type: 'final' }
}
};
}
const applicationMachine = Machine(applicationConfigGen());
const applicationInterpreter = interpret(applicationMachine);
applicationInterpreter.onTransition((state, event) => {
console.log('state:', state.value);
console.log('event:', event.type);
});
applicationInterpreter.start();
How is it different from:
send({
type: "ALERT",
data: {
prompt: "This window has timed out. What would you like to do?",
answers: [
{ label: "Keep Waiting", action: "WAIT" },
{ label: "Try Again", action: "RETRY" },
{ label: "Give Up", action: "ABORT" },
],
},
});
and
actions.pure((context, event) => {
return send({ ...event, type: "FOREGROUND" });
});
I've tested locally both versions and they yield the same results as far as I can tell.
You're, of course, correct. 😊 I just didn't know how to send using that approach. Since what I understand was semi-limited—"it is all just an action creator!"—I went with the most blunt-force approach to doing it that I found.
(I appreciate the consistent "yo, you're making this harder on yourself than necessary" comments. I'm going to turn this into something, promise.)
Most helpful comment
A little sad to lose
send(type, payload)from the interpreter — imho, it's a very intuitive expansion of thesend(string)form. That said, a consistent, simpler API for core probably makes the most sense.