It's an experimentation to show that implementing clearTimeout
is necessary for XState to work properly with a custom clock.
We created two scripts: src/with-clear-timeout.ts
and src/without-clear-timeout.ts
, that use the same machine.
For src/with-clear-timeout.ts
, we implement clearTimeout
method of the custom clock and we get the following expected logs:
state transition to: ping
state transition to: pong
state transition to: ping
state transition to: timerHasBeenCancelled
service reached final state: timerHasBeenCancelled
For src/without-clear-timeout.ts
, we don't implement clearTimeout
and timeouts are not cleared. We get the following logs:
state transition to: ping
state transition to: pong
state transition to: ping
state transition to: timerHasNotBeenCancelled
service reached final state: timerHasNotBeenCancelled
Warning: Event "xstate.after(15000)#(machine)" was sent to stopped service "(machine)". This service has already reached its final state, and will not transition.
Event: {"type":"xstate.after(15000)#(machine)"}
Warning: Event "xstate.after(7000)#(machine).ping" was sent to stopped service "(machine)". This service has already reached its final state, and will not transition.
Event: {"type":"xstate.after(7000)#(machine).ping"}
Warning: Event "xstate.after(10000)#(machine).ping" was sent to stopped service "(machine)". This service has already reached its final state, and will not transition.
Event: {"type":"xstate.after(10000)#(machine).ping"}
As we can see, we reach the final state timerHasNotBeenCancelled
. This state should never be reached, unless timers are not cleared when they should be. Furthermore we can see that the machine, although it has been stopped because it has reached a final state, receives some timer events.
We need to take a look at the implementation of delayed transitions in XState.
If we take ping
state from our state machine as an example, we get the following explicit code, without the syntactic sugar that after
keyword is:
const clockMachine = createMachine({
// ...
states: {
ping: {
onEntry: [
send(after(10_000, 'ping'), { delay: 10_000 }),
send(after(7_000, 'ping'), { delay: 7_000 }),
],
onExit: [
cancel(after(10_000, 'ping')),
cancel(after(7_000, 'ping')),
],
on: {
[after(10_000, 'ping')]: {
target: 'timerHasNotBeenCancelled'
},
[after(7_000, 'ping')]: {
target: 'pong'
},
},
},
},
// ...
});
The key to understand how this works is the after
action that can be imported from xstate/lib/actions
. It behaves as follows:
import { after } from 'xstate/lib/actions';
after(10_000, 'ping')
// xstate.after(10000)#ping
It computes the name of an event from a delay and a source state id. There is no random unique identifier, it is a computation between a delay and a state id. This function is idempotent, if we provide the same parameters, it will always return the same result.
It means that if a timer is not cancelled when exiting the state where it has been created, and if we go back to this state, it will actually trigger an event once it resolves, and this event will be catched by handlers of the state. In our case, a transition to timerHasNotBeenCancelled
state will be taken.
To have a fully working custom clock we need to always provide both a setTimeout
function and a clearTimeout
function. If clearTimeout
does not effectively clear the timer created by setTimeout
, race conditions will occur.
Be sure to run scripts with a version of Node.js that supports ESM, at least Node.js 14.x.
If you want to test the scripts locally, follow those steps:
- Install dependencies:
yarn instal
- Launch working script:
yarn start:with-timeout
- Launch broken script:
yarn start:without-timeout