Pomodoro State Machine using XState
Andrรฉ Crimberg
Posted on February 12, 2022
State machines and XState are definitely a hot๐ฅ topic these days and beyond the many examples that one can find online, I wanted to try it out creating a finite state machine for a "home made" Pomodoro project.
A bit about XState
According to the documentation itself:
XState is a library for creating, interpreting, and executing finite state machines and statecharts, as well as managing invocations of those machines as actors. The following fundamental computer science concepts are important to know how to make the best use of XState, and in general for all your current and future software projects.
This library has a rich and well maintained documentation, which I totally encourage to go over ๐ค
XState main page
Pomodoro technique ๐
This is popular method for time management, in which you alternate pomodoros (focused work sessions) with short and long breaks, promoting a sustained concentration and avoiding mental fatigue.
XState implementation
This machine has 4 states
-
FOCUS
(initial state) SHORT_BREAK
LONG_BREAK
-
DONE
(final state)
FOCUS
, SHORT_BREAK
and LONG_BREAK
will also have inner states to control whether the current state is RUNNING
or PAUSED
. RUNNING
is the initial state that we can be toggled through PAUSE
and CONTINUE
transitions.
Both states will transition back to FOCUS
once COMPLETED
is fired.
We will be using context to control:
- focus (focus time in minutes)
- shortBreak (in minutes)
- longBreak (in minutes)
- intervalsForLongBreak
- completedSections
- pausedTime (Date set when FOCUS, SHORT_BREAK or LONG_BREAK get paused)
- sectionTimeout (Timeout date for FOCUS, SHORT_BREAK or LONG_BREAK)
But you might be asking yourself: "How will the machine control the sections and trigger the right transitions alone?"
Well, with XState we can use Actions
, which are also commonly known as effects or side-effects. With these actions we will be controlling the sections.
The approach here was to invoke a service timerInterval
on every RUNNING
inner state. This interval will check always the time difference (seconds) between sectionTimeout
and now
.
services: {
timerInterval: ctx => cb => {
const interval = setInterval(() => {
const now = new Date()
const timeDiff = ctx.sectionTimeout ?
Math.ceil((ctx.sectionTimeout.valueOf() - now.valueOf()) / 1000) :
undefined
if (timeDiff !== undefined && timeDiff <= 0) {
cb('COMPLETED')
}
}, 1000)
return () => clearInterval(interval)
}
}
Since sectionTimeout
is a specific date in the feature (now
+ focus time), it's getting updated on FOCUS entry
through setSectionTimeoutFromFocus
. We also need to handle cases when the user simply pauses the machine.
To do so, we are managing pausedTime
with clearPausedTime
and setPausedTime
and then using it's value to update sectionTimeout
properly, meaning that every time that the user unpauses the machine sectionTimeout
should be updated with now + (sectionTimeout - pausedTime)
.
[TimerStates.FOCUS]: {
entry: ['setSectionTimeoutFromFocus', 'clearPausedTime'],
initial: SectionStates.RUNNING,
states: {
[SectionStates.RUNNING]: {
entry: 'setSectionTimeoutFromDiffPausedTime',
invoke: {
id: 'timerInterval',
src: 'timerInterval'
},
on: {
PAUSE: SectionStates.PAUSED
}
},
[SectionStates.PAUSED]: {
entry: 'setPausedTime',
on: {
CONTINUE: SectionStates.RUNNING
}
}
}
}
actions: {
setSectionTimeoutFromFocus: assign({
sectionTimeout: ctx => {
const newSectionTimeout = new Date()
newSectionTimeout.setMinutes(newSectionTimeout.getMinutes() + ctx.focus)
return newSectionTimeout
}
}),
setSectionTimeoutFromDiffPausedTime: assign({
sectionTimeout: ctx => {
if (!ctx.sectionTimeout || !ctx.pausedTime) {
return ctx.sectionTimeout
}
const sectionTimeLeft = Math.ceil(
ctx.sectionTimeout.valueOf() - ctx.pausedTime.valueOf()
)
const newSectionTimeout = new Date()
newSectionTimeout.setMilliseconds(newSectionTimeout.getMilliseconds() + sectionTimeLeft)
return newSectionTimeout
}
})
}
We are adding sectionsCompleted
and focusCompletedGoToLongBreak
guarded transitions to decide the next state transition, once COMPLETED
is triggered (timerInterval
service).
completedSections
gets also incremented on FOCUS exit
.
exit: 'increaseCompletedSections',
on: {
COMPLETED: [
{
target: TimerStates.DONE,
cond: 'sectionsCompleted'
},
{
target: TimerStates.LONG_BREAK,
cond: 'focusCompletedGoToLongBreak'
},
{
target: TimerStates.SHORT_BREAK
}
]
}
guards: {
focusCompletedGoToLongBreak: ctx =>
(ctx.completedSections + 1) % ctx.intervalsForLongBreak === 0,
sectionsCompleted: ctx => ctx.sections === ctx.completedSections + 1
}
SHORT_BREAK
and LONG_BREAK
states have the same structure as FOCUS
, with the exception that their COMPLETED
transitions lead back to FOCUS
.
[TimerStates.SHORT_BREAK]: {
entry: ['setSectionTimeoutFromShortBreak', 'clearPausedTime'],
initial: SectionStates.RUNNING,
states: {
[SectionStates.RUNNING]: {
entry: 'setSectionTimeoutFromDiffPausedTime',
invoke: {
id: 'timerInterval',
src: 'timerInterval'
},
on: {
PAUSE: SectionStates.PAUSED
}
},
[SectionStates.PAUSED]: {
entry: 'setPausedTime',
on: {
CONTINUE: SectionStates.RUNNING
}
}
},
on: {
COMPLETED: TimerStates.FOCUS
}
},
[TimerStates.LONG_BREAK]: {
entry: ['setSectionTimeoutFromLongBreak', 'clearPausedTime'],
initial: SectionStates.RUNNING,
states: {
[SectionStates.RUNNING]: {
entry: 'setSectionTimeoutFromDiffPausedTime',
invoke: {
id: 'timerInterval',
src: 'timerInterval'
},
on: {
PAUSE: SectionStates.PAUSED
}
},
[SectionStates.PAUSED]: {
entry: 'setPausedTime',
on: {
CONTINUE: SectionStates.RUNNING
}
}
},
on: {
COMPLETED: TimerStates.FOCUS
}
}
Wrapping up
The entire implementation is available on GitHub (timeTrackerMachine.ts)
andrecrimb / pomodoro_rn
Track your time and increase your productivity using the Pomodoro method.
I hope you enjoyed this article, if yes then don't forget to press ๐
See you next time ๐๐ฝ
Posted on February 12, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.