Building iOS Stopwatch functionality using XState

Have tinkered with JavaScript majorly | I like to solve problems on Stack Overflow, write blog articles, create a side project or do something creative.
Search for a command to run...

Have tinkered with JavaScript majorly | I like to solve problems on Stack Overflow, write blog articles, create a side project or do something creative.
No comments yet. Be the first to comment.
Introduction Alright, alright, alright !!! I have officially completed three years at Hashnode now. If you have been following my blog for a while, I have this ritual of documenting what all went in the past year at my job. My general process for do...

Introduction Well here we are reflecting on my time at Hashnode the previous year. I have officially completed two years at Hashnode and as a part of ongoing ritual where I document the experience, here is another one.And also, in first quarter of 20...

Introduction One of the things when using the NextJS ecosystem that hasn't yet clicked for me is their Script component. https://nextjs.org/docs/pages/building-your-application/optimizing/scripts I have been running some experiments on how I can le...

Introduction This article will touch upon scenarios that I have encountered while working at Hashnode where some code reviews helped us prevent future rework on the same issue by having a broader perspective. Code Review 0 The Problem Sometime back,...

Introduction You're scrolling through Twitter (I still like calling it that) or Medium and you get a nice view of the sidebar as it snaps to the bottom or top of the page if you try to scroll beyond its boundaries. We will re-create the same experie...


I have been meaning to try out xstate for long now by building a stopwatch example using it. For functionality, I referred to iOS's stopwatch in their native Clock App.
But why stopwatch ? Well, I was once asked in an interview to build the same and I struggled with it. For me, the struggling bit was visualizing the states the stopwatch can be. This is the code I submitted post interview (the interviewer was very considerate and told me that though I couldn't complete the implementation within time span, I can submit the solution later.)
function StopWatch() {
this.timerStart = 0;
this.timerEnd = 0;
let time = { hours: 0, minutes: 0, seconds: 0 };
let interval;
this.setTime = function () {
if (this.timerEnd) {
time.seconds = (this.timerEnd / 1000) % 60;
if (time.seconds === 0) {
time.minutes += 1;
}
if (time.minutes === 60) {
time.hours += 1;
time.minutes = 0;
}
}
};
this.getTime = function () {
return `${time.hours}:${time.minutes}:${time.seconds}`;
};
this.start = function () {
this.timerEnd = this.timerStart;
console.log(this.getTime());
interval = setInterval(() => {
this.timerEnd = this.timerEnd + 1000;
this.setTime();
console.log(this.getTime());
}, 1000);
};
this.stop = function () {
clearInterval(interval);
this.timerStart = this.timerEnd;
};
this.reset = function () {
clearInterval(interval);
this.timerStart = 0;
this.timerEnd = 0;
time = { hours: 0, minutes: 0, seconds: 0 };
};
}
Not my best code and actually doesn't feel intuitive enough. There are still no states for the stopwatch but merely functions that can start, stop or reset the timer and also redundant variables. So we will tackle the non-intuitiveness of this solution using xstate.
Here is what the iOS stopwatch looks like in it's initial state
Here is what the iOS stopwatch looks like in it's running state
Here is what the iOS stopwatch looks like in it's paused state
Using the app helped me land at the possible states faster.
Now before we proceed, I had no clue how the whole API works to create a state machine using xstate. So I went to their docs and landed on the stately visualizer where one can load the default example (a machine to visualize possible states when you do a fetch operation). One can just click on the Visualize button and also see a neat Statechart diagram on the screen depicting the actions and states of the machine.
So I begin with modifying this existing machine to create the stopWatchMachine.
Note :- The default code playground was in TypeScript and I stuck to it so you can ignore type specific tokens in the code I am going to share further.
In the screenshots of iOS stopwatch feature, you will notice that there also is a Lap button which is used create laps to measure in-between time intervals. We will get eventually to that but for starters, let's focus on start, stop and reset functionality.
Alright then, let's start with laying out some code now :-
import { createMachine } from "xstate";
interface Context {
elapsedTime: number;
}
const stopWatchMachine = createMachine<Context>({
id: "stopWatch",
initial: "initial", // Note this can be anything - I just like to call it initial itself.
context: {
elapsedTime: 0
},
states: {
initial: {
on: {
PRESS_START: "running"
}
}
}
})
Explanation :
initial denotes what our starting state is going to be when this state machine is created.context is an object which holds the variables on which we actually want to operate. So in our case, we care about how much time has elapsed since you hit start on your stopwatch. We are measuring the value using the elapsedTime variable. states object which really forms the basis of the whole statechart paradigm. Our first state in it is initial which itself is an object containing an on key. This on key contains key-value pairs of event : state to transition when that event happens i.e. when the PRESS_START event happens, we want our state machine to transition to running state. Note - Don't worry if you don't see anything like running state now. We will get to it in the next step. If you try to click on Visualize button at this point, the code playground will complain about running state not being present.
import { createMachine, assign } from "xstate";
interface Context {
elapsedTime: number;
}
const stopWatchMachine = createMachine<Context>({
id: "stopWatch",
initial: "initial",
context: {
elapsedTime: 0
},
states: {
initial: {
on: {
PRESS_START: "running"
}
},
running: {
on: {
PRESS_STOP: "paused",
TICK: {
actions: assign({
elapsedTime: (context) => {
return (context.elapsedTime += 20);
}
})
},
},
invoke: {
src: () => (cb) => {
const interval = setInterval(() => {
cb("TICK");
}, 20);
return () => {
clearInterval(interval);
};
}
}
},
}
})
Explanation : Sorry for bombarding you with a lot of code in one step but that's really what's happening while our stopwatch is in running state.
running state, we have a on binding for PRESS_STOP which should bring the machine in the paused state. elapsedTime variable in a timer fashion. We know that a clock/timer works on basis of a certain TICK which happens every few milliseconds. So as soon as we enter this running state, we want to start a timer. To do this, we need to setup the invoke property first. setInterval API. Instead of modifying the elapsedTime within invoke object, we will emit a TICK event every 20 milliseconds (chose this for precision) using the setInterval API. The cb is supplied by xstate and is used to send any events to the parent (in this case - running state is the parent). Also that clearInterval bit is not necessary but it's good to perform memory cleanups. TICK event inside the running block also. Inside this TICK event, we want a certain action to be performed. This action should assign our elapsedTime context variable a certain value. So we simply increment context.elapsedTime by 20 on each TICK and return it. import { createMachine, assign } from "xstate";
interface Context {
elapsedTime: number;
}
const stopWatchMachine = createMachine<Context>({
id: "stopWatch",
initial: "initial",
context: {
elapsedTime: 0
},
states: {
initial: {
on: {
PRESS_START: "running"
}
},
running: {
on: {
PRESS_STOP: "paused",
TICK: {
actions: assign({
elapsedTime: (context) => {
return (context.elapsedTime += 20);
}
})
},
},
invoke: {
src: () => (cb) => {
const interval = setInterval(() => {
cb("TICK");
}, 20);
return () => {
clearInterval(interval);
};
}
}
},
paused: {
on: {
PRESS_START: "running",
PRESS_RESET: {
target: "initial",
actions: assign({
elapsedTime: (context) => {
return (context.elapsedTime = 0);
},
})
}
}
}
}
})
Explanation :
paused state, currently, a user can press start to resume and go back to running state or a user can press reset to go back to the initial state. The on bindings depict the above statement in a declarative fashion. PRESS_RESET event binding. Here we want changes to elapsedTime as well as a state transition to initial when this event gets emitted. context.elapsedTime is reset to 0 using an action. This partially completes the building of our state machine which can help us perform start, stop and reset operations of our stopwatch. We can create a stopWatchService like so and start using it :-
import { interpret} from "xstate";
const stopWatchService = interpret(stopWatchMachine);
stopWatchService.start();
// Now you can emit the events
stopWatchService.send({ type: "PRESS_START" });
(async () => {
// delay is a promise wrapper on setTimeout
await delay(2000);
stopWatchService.send({ type: "PRESS_STOP" });
await delay(2000);
stopWatchService.send({ type: "PRESS_START" });
await delay(2000);
stopWatchService.send({ type: "PRESS_STOP" });
await delay(2000);
stopWatchService.send({ type: "PRESS_RESET" });
await delay(2000);
stopWatchService.send({ type: "PRESS_START" });
await delay(5000);
})();
In case you want to listen to the state changes (which is needed if you want to consume the elapsedTime value or see what the current state of machine is), you can do so using callback inside onTransition function :-
const stopWatchService = interpret(stopWatchMachine);
stopWatchService.onTransition((state)=>{
console.log(state.context.elapsedTime, state.value)
})
stopWatchService.start();
// Start emitting events as shown in above code block
Before be proceed, here is how the Lap UI looks in the iOS app :-
You can see that once a user hits the Lap button, an entry of that lap is added to a list as shown below the timer. So this means that besides elapsedTime, we also have to maintain a laps context variable which will start as an empty []. Also Lap button is only enabled for the user when the stopwatch is in its running state. This gives us enough info to add the lap feature to our state machine like so :-
import { createMachine, assign } from "xstate";
interface Context {
elapsedTime: number;
laps: Array<{ startTime: number; elapsedTime: number }>;
}
const stopWatchMachine = createMachine<Context>({
id: "stopWatch",
initial: "initial",
context: {
elapsedTime: 0,
laps: []
},
states: {
initial: {
on: {
PRESS_START: "running"
}
},
running: {
on: {
PRESS_STOP: "paused",
TICK: {
actions: assign({
elapsedTime: (context) => {
return (context.elapsedTime += 20);
},
laps: (context) => {
const laps = context.laps;
const latestLap = laps[laps.length - 1] ?? {
startTime: 0,
elapsedTime: 0
};
if (laps.length === 0) {
laps.push(latestLap);
}
latestLap.elapsedTime = context.elapsedTime - latestLap.startTime;
return laps;
}
})
},
PRESS_LAP: {
actions: assign({
laps: (context) => {
const laps = context.laps;
const newLap = { startTime: context.elapsedTime, elapsedTime: 0 };
laps.push(newLap);
return laps;
}
})
}
},
invoke: {
src: () => (cb) => {
const interval = setInterval(() => {
cb("TICK");
}, 20);
return () => {
clearInterval(interval);
};
}
}
},
paused: {
on: {
PRESS_START: "running",
PRESS_RESET: {
target: "initial",
actions: assign({
elapsedTime: (context) => {
return (context.elapsedTime = 0);
},
laps: (context) => {
return (context.laps = []);
}
})
}
}
}
}
});
Explanation : Holy moly, the state machine is quite bigger than before. Yes because we want to deal with setting the laps context variable on appropriate machine states.
First let's see the introduction of PRESS_LAP event which can happen when machine is in running state. If that event happens, we don't transition to any other state. We just push a new lap to the the laps list inside our action block. Our lap data structure is an object which has two keys -
startTime - This will be equal to the total elapsedTime on the timer when the PRESS_LAP event got emitted. elapsedTime - This will be the elapsedTime for that lap. We will see its derivation next. Now, we already have a TICK event that happens every 20 seconds in the machine's running state. So now we also handle the laps context variable in the TICK event's action block. We only want the latestLap to keep respecting the dynamic timer. So we check the last element of laps list. If it's not there (for first lap), we insert one ourself with startTime as 0 and elapsedTime as 0. For each latestLap, we need to determine its elapsedTime. This can be done by subtracting the latestLap's startTime (Remember we set it when the PRESS_LAP event got triggered) from the total elapsedTime of the timer. After setting the latestLap, we return the laps object.
Also in our paused state, we have the PRESS_RESET event definition. Earlier we only reset the elapsedTime to 0. Now we will also reset laps to [].
This finally completes the building of our state machine which can help us perform start, stop, lap and reset operations of our stopwatch. Interpreting and starting the machine will remain same.
Following is the visualizer diagram of our final state machine :-

Also here is a codesandbox implementation with UI as well. The UI is not polished and doesn't do justice to iOS styling. It's just to see our stopWatchMachine powering an UI :-
It's the declarative API of xstate which really abstracts that nested if/switch case complexity when one is building an application. Once the machine was setup, I liked how deterministic the behaviour was. Overall it was fun to scratch the surface of xstate by building this example. There also is a low-code way of making the statechart first which automatically create a machine for you. Haven't tried that but that sounds very cool.

Thank you for your time :)