External transition to child state

All transitions are external by default with XState, except transitions with a relative target, that are internal. It can lead to bugs when not understood properly.

In a XState machine there are two types of transitions: external transitions and internal transitions. There are identical except in one case: when targeting a child state. By default, all transitions are external, except when the transition has a relative target to a child state. Let’s see how it can influence a state machine, and cause unexpected behaviours.

permalinkRelative target to a child state

As an example, we want to create a state machine that sends a request whatever the current state is, and aborts pending sendings if there are.

We start with this state machine:

ts
import { createMachine } from "xstate";
interface Context {}
type Events = { type: "TRIGGER" } | { type: "SENT_REQUEST" };
createMachine<Context, Events>(
{
initial: "processing",
states: {
processing: {
initial: "idle",
states: {
idle: {},
sending: {
invoke: {
src: "sendRequest",
},
on: {
SENT_REQUEST: {
target: "idle",
},
},
},
},
on: {
TRIGGER: {
target: ".sending",
},
},
},
},
},
{
services: {
sendRequest: () => (sendBack) => {
console.log("invoked sendRequest");
const timerId = setTimeout(() => {
console.log("acknowledge request sending");
sendBack({
type: "SENT_REQUEST",
});
}, 1_000);
return () => {
console.log("exiting sendRequest");
clearTimeout(timerId);
};
},
},
}
);

We want to send a request each time processing state receives a TRIGGER event. We implement two states in processing: processing.idle and processing.sending. In processing.idle, we do nothing special, and in processing.sending, we invoke sendRequest service that sends back a SENT_REQUEST event after one second. When we receive this event, we go back from processing.sending to processing.idle. To send the request, when receiving a TRIGGER event we go from processing state to its child processing.sending with a relative target: .sending.

You can try the example live and open your console to see logs. Try sending TRIGGER event while being on processing.sending state. We would expect to see sendRequest service stopped and reinvoked immediately but it doesn’t occur.

This is because we used a relative target to target a child state, that is .sending. With XState, transitions with a relative target are by default internal. The difference between an internal transition and an external transition is that an internal transition will not exit the active state nor enter the target state if the target is the active state. So when receiving a TRIGGER event while being on processing.sending state, the state is not exited, nor reentered, therefore sendRequest service is not restarted.

To fix that, we need an external transition.

permalinkMake transition with relative target external

The first way to make a transition with a relative target external, is to tell to XState to do not make that transition internal.

We can do that that way:

ts
import { createMachine } from "xstate";
interface Context {}
type Events = { type: "TRIGGER" } | { type: "SENT_REQUEST" };
createMachine<Context, Events>(
{
initial: "processing",
states: {
processing: {
initial: "idle",
states: {
idle: {},
sending: {
invoke: {
src: "sendRequest",
},
on: {
SENT_REQUEST: {
target: "idle",
},
},
},
},
on: {
TRIGGER: {
target: ".sending",
internal: false,
},
},
},
},
},
{
services: {
sendRequest: () => (sendBack) => {
console.log("invoked sendRequest");
const timerId = setTimeout(() => {
console.log("acknowledge request sending");
sendBack({
type: "SENT_REQUEST",
});
}, 1_000);
return () => {
console.log("exiting sendRequest");
clearTimeout(timerId);
};
},
},
}
);

Now if we interact with the state machine, we see that sending a TRIGGER event while being in processing.sending state will actually stop sendRequest service and reinvoke it. Perfect!

On TRIGGER transition we explicitly tell XState to do not make this transition internal by using internal: false.

permalinkMake external transition to source state and its child

In SCXML specification, the source state of a transition is the state that defines the transition. In our state machine, the source state of the transition triggered by TRIGGER event is processing. We can make an external transition by targeting processing state itself, and its child we want to enter on, i.e. sending. We end up with a transition target such as processing.sending.

This is possible because a state node is considered to be its own sibling. And as all sibling transitions are by default external, we will exit active state and reenter on it.

See how it works:

ts
import { createMachine } from "xstate";
interface Context {}
type Events = { type: "TRIGGER" } | { type: "SENT_REQUEST" };
createMachine<Context, Events>(
{
initial: "processing",
states: {
processing: {
initial: "idle",
states: {
idle: {},
sending: {
invoke: {
src: "sendRequest",
},
on: {
SENT_REQUEST: {
target: "idle",
},
},
},
},
on: {
TRIGGER: {
target: "processing.sending",
},
},
},
},
},
{
services: {
sendRequest: () => (sendBack) => {
console.log("invoked sendRequest");
const timerId = setTimeout(() => {
console.log("acknowledge request sending");
sendBack({
type: "SENT_REQUEST",
});
}, 1_000);
return () => {
console.log("exiting sendRequest");
clearTimeout(timerId);
};
},
},
}
);

It should work as expected.

permalinkSide note: SCXML specification

It seems that XState does not follow strictly SCXML specification on internal transitions.

The specification describes internal transitions behaviour as follows:

The behavior of transitions with ‘type’ of “internal” is identical [except that] an internal transition will not exit and re-enter its source state

In other words and if I understand specification correctly, the issue we encountered would not have existed with a totally SCXML compliant interpreter. processing.sending state would have exited and reentered but not its parent state. This is described with an example on the specification linked above.

I do not have to interpret state machines following scrupulously SCXML specification so I am not really concerned. But it’s something to know when you have to.

permalinkConclusion

Transitions with a relative target are internal by default with XState. We can make them external by setting internal property of the transition to false explicitly, or by targeting the source state and its child state. That way the child state is cleaned up properly. It can be useful to reset services or delayed transitions.

I personally tend to use the source state and its child state as the target, like with processing.sending. It relies on the default behaviour of almost all transitions: being external.

There are other ways to solve that problem. For instance, we could move the transition to each child state of processing and target sending as a sibling state. Although this would involve some duplication.

Join my mailing list

Get monthly insights, personal stories, and in-depth information about what I find helpful in web development as a freelancer.

Email advice once a month + Free XState report + Free course on XState

Want to see what it looks like? View the previous issues.

I value your privacy and will never share your email address.