DEV Community 👩‍💻👨‍💻

Cover image for Create a Simple React Timer Component ⌛
Diego Castillo
Diego Castillo

Posted on

Create a Simple React Timer Component ⌛

I have recently been developing a workout application during my spare time. One of the requirements I set for it was to create a timer so that users could keep track of their workouts. The primary goal was to build a timer which featured a way to "play", "pause", and "stop" a workout. Additionally, it would need to store enough information so that questions such as "How much time did the user take to complete the exercise?" or "How much time did it take to complete the entire workout?" could be answered.

In this blog post, I will explain a simple implementation of a timer component in React that can be extended to answer the aforementioned questions. At the end, there are links to a CodeSandbox demo and the GitHub repository of the code.

The Plan 💡

The main idea was to create an entity which would allow to store all the information that is needed. This entity would store when it started, paused, and for how much time it ran. Let's call this entity a "time entry" and define it as follows:

{
  startedAt: Integer, // The # of elapsed ms since the unix epoch
  elapsedMs: Integer // If paused, the # of ms this time entry ran
}
Enter fullscreen mode Exit fullscreen mode

A workout would then be defined as a list of time entries. In other words, each time the user started the timer, it would initialize a time entry and set startedAt to "now". It would keep running unless paused, in which case the number of elapsed milliseconds since it was started would be computed and stored in elaspedMs. If the timer is started again, then a new time entry would be created. Finally, computing the total elapsed time would simply require adding up all the time entries' elapsedMs .

The Timer Reducer ⚒️

Let's go ahead and implement it using CRA to simplify the process. Run npx create-react-app react-timer-app to create the application.

I'll be using the "State Reducer Pattern" as explained by Kent C. Dodds. Let's start by defining a simple skeleton of the timer reducer, the actions the user will be allowed to perform, and the useTimer hook in App.js as follows:

const actionTypes = {
  tick: 'tick',
  play: 'play',
  pause: 'pause',
  stop: 'stop',
}

const initialState = {
  tick: null,
  timeEntries: [],
}

const timerReducer = (state, { type, payload }) => {
  switch (type) {
    case actionTypes.tick:
      return state
    case actionTypes.play:
      return state
    case actionTypes.pause:
      return state
    case actionTypes.stop:
      return state
    default:
      throw new Error(`Unhandled type: ${type}`)
  }
}

const useTimer = () => {
  const [state, dispatch] = useReducer(timerReducer, initialState)

  return {}
}

const Timer = () => {
  return null
}

const App = () => {
  return <Timer />
}
Enter fullscreen mode Exit fullscreen mode

The tick Action

The tick action will be used to re-render the <Timer/> component every second. To do this, the component will use the useInterval hook as implemented by Dan Abramov in this blog post . Every second, this action will be fired with "now" (the number of milliseconds since the unix epoch) as its payload. The payload is then assigned to the tick property of the timer reducer's state.

case actionTypes.tick:
  return { ...state, tick: payload }
Enter fullscreen mode Exit fullscreen mode
// The number of ms since the unix epoch (a.k.a. "now")
const now = () => new Date().getTime()

const useTimer = () => {
  const [state, dispatch] = useReducer(timerReducer, initialState)

  const tick = () => dispatch({ type: actionTypes.tick, payload: now() })

  return {
    tick,
  }
}

const Timer = () => {
  const { tick } = useTimer()

  useInterval(() => {
    tick()
  }, 1000)

  return null
}
Enter fullscreen mode Exit fullscreen mode

The play Action

The play action is in charge of starting the timer at "now". Before implementing this action, there are a few utility functions that will need to be defined, though.

First, let's add these functions which will make it easier to deal with a time entry. These will help to create, stop, and easily determine a time entry's "status":

// Create a new time entry starting "now" by default
const startTimeEntry = (time = now()) => ({
  startedAt: time,
  elapsedMs: null,
})

// Stop the given time entry at "now" by default
const stopTimeEntry = (timeEntry, time = now()) => ({
  ...timeEntry,
  elapsedMs: time - timeEntry.startedAt,
})

// Return true if a time entry is running, false otherwise
const isTimeEntryRunning = ({ elapsedMs }) => elapsedMs === null

// Return true if a time entry is paused, false otherwise
const isTimeEntryPaused = ({ elapsedMs }) => elapsedMs !== null
Enter fullscreen mode Exit fullscreen mode

Next, let's define some more utility functions, but this time to help derive information from the useTimer hook state (a.k.a. "selectors"):

// Get the current time entry, which is always the latest one
const getCurrTimeEntry = (state) =>
  state.timeEntries[state.timeEntries.length - 1]

// Return true if the timer is stopped, false otherwise
const isStopped = (state) => state.timeEntries.length === 0

// Return true if the timer is running, false otherwise
const isRunning = (state) =>
  state.timeEntries.length > 0 && isTimeEntryRunning(getCurrTimeEntry(state))

// Return true if the timer is paused, false otherwise
const isPaused = (state) =>
  state.timeEntries.length > 0 && isTimeEntryPaused(getCurrTimeEntry(state))

// Return the total number of elapsed ms
const getElapsedMs = (state) => {
  if (isStopped(state)) return 0

  return state.timeEntries.reduce(
    (acc, timeEntry) =>
      isTimeEntryPaused(timeEntry)
        ? acc + timeEntry.elapsedMs
        : acc + (now() - timeEntry.startedAt),
    0
  )
}
Enter fullscreen mode Exit fullscreen mode

These methods will make it easy to know what is the current time entry, if the timer is running, paused, or stopped, and how much time has passed since it was started.

Alright, those were a lot of utility functions! Let's focus in the play action implementation:

case actionTypes.play:
  if (isRunning(state)) return state

  return {
    ...state,
    timeEntries: state.timeEntries.concat(startTimeEntry(payload)),
  }
Enter fullscreen mode Exit fullscreen mode

The play action can only be executed if the timer isn't currently running, thus the state is returned as it's unless that's the case. Otherwise, a new time entry is "started" (created) and added to the list of time entries.

The pause Action

The pause action can only be executed if timer is running. It will find the currently running time entry (the last one), and compute the number of elapsed milliseconds since it started until now (i.e., how much time it ran for). Here's the implementation:

case actionTypes.pause:
  if (isStopped(state)) return state
  if (isPaused(state)) return state

  const currTimeEntry = getCurrTimeEntry(state)
  return {
    ...state,
    timeEntries: state.timeEntries
      .slice(0, -1)
      .concat(stopTimeEntry(currTimeEntry)),
  }
Enter fullscreen mode Exit fullscreen mode

The stop Action

The stop action removes all the existing time entries to stop the timer and can be executed at any time. Its implementation is straightforward:

case actionTypes.stop:
  return { ...state, timeEntries: [] }
Enter fullscreen mode Exit fullscreen mode

The useTimer Hook

Now that the timer reducer has been implemented, the useTimer hook will expose its API to consumers as follows:

const useTimer = () => {
  const [state, dispatch] = useReducer(timerReducer, initialState)

  const pause = () => dispatch({ type: actionTypes.pause, payload: now() })
  const play = () => dispatch({ type: actionTypes.play, payload: now() })
  const stop = () => dispatch({ type: actionTypes.stop })
  const tick = () => dispatch({ type: actionTypes.tick, payload: now() })

  const running = isRunning(state)
  const elapsedMs = getElapsedMs(state)

  return {
    pause,
    play,
    running,
    stop,
    tick,
    elapsedMs,
  }
}
Enter fullscreen mode Exit fullscreen mode

The useTimer consumer is the <Timer/> component, and its implementation could look like this (very simplified and with no styles whatsoever for brevity):

const Timer = () => {
  const { pause, play, running, stop, tick, elapsedMs } = useTimer()

  const zeroPad = (x) => (x > 9 ? x : `0${x}`)
  const seconds = Math.floor((elapsedMs / 1000) % 60)
  const minutes = Math.floor((elapsedMs / (1000 * 60)) % 60)
  const hours = Math.floor((elapsedMs / (1000 * 60 * 60)) % 24)

  useInterval(() => {
    tick()
  }, 1000)

  return (
    <div>
      <p>
        {zeroPad(hours)}:{zeroPad(minutes)}:{zeroPad(seconds)}
      </p>
      {running ? (
        <button onClick={pause}>pause</button>
      ) : (
        <button onClick={play}>play</button>
      )}
      <button onClick={stop}>stop</button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Conclusion 🤝

Alright, that was a bit longer than I anticipated. The idea of using time entries to store the timer's state can be extended to include more information in each time entry, and thus be able to answer questions such as the ones I posted in the introduction. There's a CodeSandbox demo of the <Timer/> component and also a GitHub repo with all the code needed. Post a comment below if you have a question or idea to share 🙂.

Top comments (0)

Create an Account! The only reason people scroll to the bottom...  
is because they want to read more.

Create an account to bookmark, comment, and react to articles that interest you.