Building a Timer in React
January 09, 2019
Writing a React project in Typescript has been pretty darn fun. The compiler can be quite tricky at times, but the reward for sticking with it is the confidence that a lot (but not all) of the typical errors seen in Javascript development won't come up at all. Undefined is not a function? Not too likely in Typescript. All of the common errors that have something to do with trying to snap two components together incorrectly is pointed out to you as you type it. And since reading stack traces on vanilla JS compiled from ES6 can be a bit daunting, I prefer to just sort out those issues as I write them.
One challenge I gave myself in my quest to learn Typescript is to implement a pomodoro app. One essential component in that would of course be a Timer
. Here's how I did mine:
Outline
To start, let's think about home of the user stories related to the Timer
- As a user, I can start a timer for an amount of time I chose.
- As a user, I can stop the timer.
- As a user, I can start another timer for a different task causing the currently running timer to stop
- When the timer runs out, a sound is played and the user is prompted to write a reflection
To achieve this, we'll need several components working together:
- a
Task
- a
StartButton
- a
StopButton
- and the
Timer
In this app, a task represents a single task or project at work. Many pomodoros can be run for a task. A user can have many tasks, but can only run a single pomodoro at a time. If a pomodoro is running, and another one is started, the first pomodoro is ended early.
Task
A Task
is going to be a stateless functional component and accepts the following props
- the
id
of the task - the
name
of the task - data on the current
runningTask
from the redux provider (stored inside an object calledpomodoros
) - a to function to handle starting a task on the task called
onStartPomodoro
- a similar function to handle stoping a pomodoro on the task called
onStopPomodoro
Here's a simplified implementation
import * as React from 'react';
import StartButton from './StartButton'
import StopButton from './StopButton'
import {TaskI} from '../../interfaces/task'
import Timer from '../containers/Timer'
interface TaskProps extends TaskI {
key: number,
pomodoros: {
runningTask: Number
},
onStartPomodoro: Function,
onStopPomodoro: Function
}
const Task: React.SFC<TaskProps> = ({ id, name, classes, pomodoros, onStartPomodoro, onStopPomodoro}) => {
const isRunning = pomodoros.runningTask === id
const button = isRunning ?
<StopButton stop={onStopPomodoro} /> :
<StartButton start={onStartPomodoro} taskId={id} />
return(
<div className="task">
<h2 className="task__name">{name}</h2>
<div className="task__button">
{button}
</div>
{isRunning && <Timer minutes="25" seconds="0"/>}
</div>
)
}
export default Task;
Here's what this component does: if the task is running a pomodor0 (determined by comparing the task id with the runningTask
value in the redux store) the task will be rendering the timer and a StopButton
, otherwise a StartButton
.
StopButton and StartButton
The StopButton
isn't much
import * as React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import IconButton from '@material-ui/core/IconButton';
interface I {
stop: Function
}
const StopButton: React.SFC<I> = ({ stop }) => {
return (
<IconButton color="secondary" aria-label="Stop Pomodoro" onClick={() => stop()}>
<FontAwesomeIcon icon="stop" style={{ color: 'black' }} />
</IconButton>
)
}
export default StopButton
It accepts one prop, a function called stop
which tells the redux store that the current task isn't running anymore. I'm using Google's material theme and a font awesome icon. When the icon is clicked the function is fired. That's pretty much it!
The StartButton
is quite similar. When the start button is click, the passed start
function tells the redux store that the current task wants to start a pomodoro.
import * as React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import IconButton from '@material-ui/core/IconButton';
interface I {
taskId: Number,
start: Function
}
const StartButton: React.SFC<I> = ({taskId, start}) => {
return(
<IconButton color="secondary" aria-label="Start Pomodoro" onClick={() => start(taskId)}>
<FontAwesomeIcon icon="play" style={{color: 'black'}}/>
</IconButton>
)
}
export default StartButton
Those two buttons let us toggle a task's state between running a pomodoro and not. We also can have multiple tasks but only one can be running a pomodoro at a given time.
Timer
Finally we come to the Timer
component itself. The Timer
is responsible for displaying the time remaining and playinga sound when its done.
It accepts two props: the minutes
and seconds
of the pomodoro. Since the component also needs to deal with time, I decided to make it stateful. Its internal state will be:
- The
timeLeft
expressed as a string. Example25:00
- The exact time when timer will be
doneAt
- if the timer is
finished
Here's what it looks like
import * as React from 'react'
import PomodoroI from '../../interfaces/pomodoro'
import * as moment from 'moment';
import Typography from '@material-ui/core/Typography';
import Sound from 'react-sound';
const pomodoroDone = require('../../media/pomodoroDone.mp3')
interface TimerProps {
minutes: Number,
seconds: Number,
}
interface TimerState {
timeLeft: String,
doneAt: moment.Moment,
finished: Boolean,
}
class Timer extends React.Component<TimerProps, TimerState> {
timerHandle: NodeJS.Timer
constructor(p: TimerProps, s: TimerState) {
super(p, s);
const {minutes, seconds} = this.props;
const timeLeft = moment.utc(moment.duration({ minutes: minutes, seconds: seconds } as moment.DurationInputObject).asMilliseconds()).format("mm:ss")
this.state = {
timeLeft: timeLeft,
doneAt: moment()
.add(minutes as unknown as moment.unitOfTime.DurationConstructor, 'minutes')
.add(seconds as unknown as moment.unitOfTime.DurationConstructor, 'seconds'),
finished: false,
}
this.timerHandle = setInterval(this.tick.bind(this), 100)
}
componentWillUnmount() {
clearInterval(this.timerHandle)
}
tick() {
const timeLeft = this.state.doneAt.diff(moment())
if (timeLeft > 0 ) {
this.setState({
timeLeft: moment.utc(timeLeft).format('mm:ss')
})
} else {
this.wrapUp()
}
}
wrapUp() {
clearInterval(this.timerHandle)
this.setState({finished: true})
}
render() {
const soundPlayStatus = this.state.running ? Sound.status.STOPPED : Sound.status.PLAYING
return(
<div>
<Typography variant = "display1">
{this.state.timeLeft}
</Typography >
<Sound url={pomodoroDone} playStatus={soundPlayStatus} />
</div>
)
}
}
export default Timer
When the component is set up, we use the moment.js library to compute the initial state. When then use setInterval
to call the tick
method. Every 100 miliseconds, we recompute the timeLeft
, and updated the timeLeft
. The effect is a timer ticking down to zero.
As an alternative, I could, have instead of using a doneAt
state, stored timeLeft
as a moment.js time and decremented it by 1 second each second. While that would have probobly worked fine as well, it seemed to me that I would be reimplementing something that the system clock does perfectly well already. This implementation establishes a concrete end time and then, when its time to update, computes time based on that fixed time.
When the time is up, we call the wrapUp
method which toggles finished
to true
which in turn causes a sound to play. I've not yet implemented the functionality to prompt the user to write a reflection, but that workflow would be triggered here as well.
Finally, there's one more thing we need to consider. What happens when the user starts another Timer
, thus causing this Timer
to unmount? It turns out that React gets sad when there is a setInterval
handler attached to a component that goes away. To remedy this, we implement componentWillUnmount
and clear the interval.