DEV Community

Cover image for Pomodoro State Machine using XState
André Crimberg
André Crimberg

Posted on

Pomodoro State Machine using XState

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.

Pomodoro app gif

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.

alt focus

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.

Pomodoro State machine Visualizer

Link to visualizer

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
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
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

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
      }
    ]
}
Enter fullscreen mode Exit fullscreen mode
guards: {
  focusCompletedGoToLongBreak: ctx =>
    (ctx.completedSections + 1) % ctx.intervalsForLongBreak === 0,
  sectionsCompleted: ctx => ctx.sections === ctx.completedSections + 1
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Wrapping up

The entire implementation is available on GitHub (timeTrackerMachine.ts)

GitHub logo 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 👋🏽

Top comments (0)