# Task 4: Timer

These XState v4 docs are no longer maintained

XState v5 is out now! Read more about XState v5 (opens new window) and check out the XState v5 docs (opens new window).

This is the fourth of The 7 Tasks from 7GUIs (opens new window):

Challenges: concurrency, competing user/signal interactions, responsiveness.

The task is to build a frame containing a gauge G for the elapsed time e, a label which shows the elapsed time as a numerical value, a slider S by which the duration d of the timer can be adjusted while the timer is running and a reset button R. Adjusting S must immediately reflect on d and not only when S is released. It follows that while moving S the filled amount of G will (usually) change immediately. When e ≥ d is true then the timer stops (and G will be full). If, thereafter, d is increased such that d > e will be true then the timer restarts to tick until e ≥ d is true again. Clicking R will reset e to zero.

Timer deals with concurrency in the sense that a timer process that updates the elapsed time runs concurrently to the user’s interactions with the GUI application. This also means that the solution to competing user and signal interactions is tested. The fact that slider adjustments must be reflected immediately moreover tests the responsiveness of the solution. A good solution will make it clear that the signal is a timer tick and, as always, has not much scaffolding.

Timer is directly inspired by the timer example in the paper Crossing State Lines: Adapting Object-Oriented Frameworks to Functional Reactive Languages (opens new window).

# Modeling

The key point in modeling this timer is in the description itself:

A good solution will make it clear that the signal is a timer tick

Indeed, we can model timer ticks as a signal (event) that updates the context of some parent timer machine. The timer can be in either the paused state or the running state, and these timer ticks should ideally only be active when the machine is in the running state. This gives us a good basis for how we can model the other requirements:

  • When in the running state, some elapsed variable is incremented by some interval on every TICK event.
  • Always check that elapsed does not exceed duration (guarded transition) in the running state (transient transition)
    • If elapsed exceeds duration, transition to the paused state.
  • Always check that duration does not exceed elapsed (guarded transition) in the paused state.
    • If duration exceeds elapsed, transition to the running state.
  • The duration can always be updated via some DURATION.UPDATE event.
  • A RESET event resets elapsed to 0.

In the running state, we can invoke a service that calls setInterval(...) to send a TICK event on the desired interval.

By modeling everything as a "signal" (event), such as DURATION.UPDATE, TICK, RESET, etc., the interface is fully reactive and concurrent. It also simplifies the implementation.

States:

  • "running" - the state where the timer is running, receiving TICK events from some invoked interval service, and updating context.elapsed.
  • "paused" - the state where the timer is not running and no longer receiving TICK events.

Context:

interface TimerContext {
  // The elapsed time (in seconds)
  elapsed: number;
  // The maximum time (in seconds)
  duration: number;
  // The interval to send TICK events (in seconds)
  interval: number;
}

Events:

type TimerEvent =
  | {
      // The TICK event sent by the spawned interval service
      type: 'TICK';
    }
  | {
      // User intent to update the duration
      type: 'DURATION.UPDATE';
      value: number;
    }
  | {
      // User intent to reset the elapsed time to 0
      type: 'RESET';
    };

# Coding

export const timerMachine = createMachine({
  initial: 'running',
  context: {
    elapsed: 0,
    duration: 5,
    interval: 0.1
  },
  states: {
    running: {
      invoke: {
        src: (context) => (cb) => {
          const interval = setInterval(() => {
            cb('TICK');
          }, 1000 * context.interval);

          return () => {
            clearInterval(interval);
          };
        }
      },
      on: {
        '': {
          target: 'paused',
          cond: (context) => {
            return context.elapsed >= context.duration;
          }
        },
        TICK: {
          actions: assign({
            elapsed: (context) =>
              +(context.elapsed + context.interval).toFixed(2)
          })
        }
      }
    },
    paused: {
      on: {
        '': {
          target: 'running',
          cond: (context) => context.elapsed < context.duration
        }
      }
    }
  },
  on: {
    'DURATION.UPDATE': {
      actions: assign({
        duration: (_, event) => event.value
      })
    },
    RESET: {
      actions: assign({
        elapsed: 0
      })
    }
  }
});

# Result