External transition to child state
Published on
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.