How to develop a Stopwatch in React JS with custom hook

abdulbasit313

Abdul Basit

Posted on July 19, 2020

How to develop a Stopwatch in React JS with custom hook

In order to truly understand how things work we should break down the task into smaller pieces, this is what we are doing here. Our ultimate goal is to build a React Native Pomodoro clock App but first, we will build a stopwatch to understand how setInterval and clearInterval works in react with hooks then turn this stopwatch into a Pomodoro clock, and so on.

Let's start

Let's break down everything and build a boilerplate first.

import React, { useState } from 'react';
import './App.css';

const App = () => {
  const [timer, setTimer] = useState(0)
  const [isActive, setIsActive] = useState(false)
  const [isPaused, setIsPaused] = useState(false)
  const countRef = useRef(null)

  const handleStart = () => {
    // start button logic here
  }

  const handlePause = () => {
    // Pause button logic here
  }

  const handleResume = () => {
    // Resume button logic here
  }

  const handleReset = () => {
    // Reset button logic here
  }

  return (
    <div className="app">
      <h3>React Stopwatch</h3>
      <div className='stopwatch-card'>
        <p>{timer}</p> {/* here we will show timer */}
        <div className='buttons'>
          <button onClick={handleStart}>Start</button>
          <button onClick={handlePause}>Pause</button>
          <button onClick={handleResume}>Resume</button>
          <button onClick={handleReset}>Reset</button>
        </div>
      </div>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

A timer will start from 0 to onward by clicking the start button.

isActive is defined to see if the timer is active or not.

isPaused is defined to see if the timer is paused or not.

Initially, both values will be false. We have defined these values to conditionally render Start, Pause, and Resume button.

UseRef hook

useRef helps us to get or control any element's reference.

It is the same as we get the reference in vanilla javascript by using document.getElementById("demo") which means we have skipped virtual dom and directly dealing with browsers. Isn't the useRef hook is powerful?

If we run this code we will see the result like this. (CSS is included at the end of the article)

Alt Text

Now we have three tasks to do,

  • to write a function for each button
  • format the timer the way we see in stopwatch (00: 00: 00)
  • conditionally rendering the buttons

Start Function

The job of start function is to start the timer and keep incrementing until we reset or pause it.

For that, we will use setInterval method. setInterval runs as long as we don't stop it. It takes two parameters. A callback and time in milliseconds.

setInterval(func, time)

1000 ms = 1 second

const handleStart = () => {
  setIsActive(true)
  setIsPaused(true)
  countRef.current = setInterval(() => {
    setTimer((timer) => timer + 1)
  }, 1000)
}
Enter fullscreen mode Exit fullscreen mode

As soon we will hit the start button, isActive and isPaused will become true and 1 will be added to timer values every second.

We set countRef current property to the setInterval function, which means we set the timerId in variable countRef, now we can use it in other functions.

We used countRef.current to get the current value of the reference.

Pause Function

setInterval keeps calling itself until clearInterval is called.

In order to stop or pause the counter we need to use clearInterval function. clearInterval needs one parameter that is id. We will pass countRef.current as arguement in clearInterval method.

const handlePause = () => {
  clearInterval(countRef.current)
  setIsPaused(false)
}
Enter fullscreen mode Exit fullscreen mode

on pressing Pause button we will stop (not reset) the timer, and change isPaused state from true to false.

Resume Function

const handleResume = () => {
  setIsPaused(true)
  countRef.current = setInterval(() => {
    setTimer((timer) => timer + 1)
  }, 1000)
}
Enter fullscreen mode Exit fullscreen mode

On resuming the timer we will start the timer from where it was paused and change isPaused from false to true.

Reset Function

const handleReset = () => {
  clearInterval(countRef.current)
  setIsActive(false)
  setIsPaused(false)
  setTimer(0)
}
Enter fullscreen mode Exit fullscreen mode

Reset function will reset everything to its initial values. This button will not only stop the counter but also reset its value to 0.

Rendering Buttons Logic

Let's talk about start, pause, and resume button rendering logic.

Once the timer starts, the start button will change into Pause, if we pause the timer we will see Resume button. This is how stopwatches work or you may say how we want this to work.

How do we know which button to show?

For that, we have already defined two keys in our state. One isActive, and other one is isPaused

And both of them will be false initially.

If both keys will be false, we will show start button. It's obvious.

What happens in case of pause?

isActive will be true, isPaused will be false

Otherwise we will show resume button

We need to write nested if else condition. Either we show start or pause/resume button.

Formatting Timer

Another tricky part of app is to show the timer in this manner 00:00:00

For seconds

const getSeconds = `0${(timer % 60)}`.slice(-2)
Enter fullscreen mode Exit fullscreen mode

For minutes

 const minutes = `${Math.floor(timer / 60)}`
 const getMinutes = `0${minutes % 60}`.slice(-2)
Enter fullscreen mode Exit fullscreen mode

For hours

const getHours = `0${Math.floor(timer / 3600)}`.slice(-2)
Enter fullscreen mode Exit fullscreen mode

We made the formatTime function for this, which returns seconds, minutes, and hours.

  const formatTime = () => {
    const getSeconds = `0${(timer % 60)}`.slice(-2)
    const minutes = `${Math.floor(timer / 60)}`
    const getMinutes = `0${minutes % 60}`.slice(-2)
    const getHours = `0${Math.floor(timer / 3600)}`.slice(-2)

    return `${getHours} : ${getMinutes} : ${getSeconds}`
  }
Enter fullscreen mode Exit fullscreen mode

In react the button has disabled props that is false by default we can make it true by adding some logic. We made reset button disabled if the timer is set to 0 just by adding simple logic disabled={!isActive}

So far Complete Code

import React, { useState, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faClock } from '@fortawesome/free-regular-svg-icons'

import './App.css';

const element = <FontAwesomeIcon icon={faClock} />

const App = () => {
  const [timer, setTimer] = useState(3595)
  const [isActive, setIsActive] = useState(false)
  const [isPaused, setIsPaused] = useState(false)
  const increment = useRef(null)

  const handleStart = () => {
    setIsActive(true)
    setIsPaused(true)
    increment.current = setInterval(() => {
      setTimer((timer) => timer + 1)
    }, 1000)
  }

  const handlePause = () => {
    clearInterval(increment.current)
    setIsPaused(false)
  }

  const handleResume = () => {
    setIsPaused(true)
    increment.current = setInterval(() => {
      setTimer((timer) => timer + 1)
    }, 1000)
  }

  const handleReset = () => {
    clearInterval(increment.current)
    setIsActive(false)
    setIsPaused(false)
    setTimer(0)
  }

  const formatTime = () => {
    const getSeconds = `0${(timer % 60)}`.slice(-2)
    const minutes = `${Math.floor(timer / 60)}`
    const getMinutes = `0${minutes % 60}`.slice(-2)
    const getHours = `0${Math.floor(timer / 3600)}`.slice(-2)

    return `${getHours} : ${getMinutes} : ${getSeconds}`
  }

  return (
    <div className="app">
      <h3>React Stopwatch {element}</h3>
      <div className='stopwatch-card'>
        <p>{formatTime()}</p>
        <div className='buttons'>
          {
            !isActive && !isPaused ?
              <button onClick={handleStart}>Start</button>
              : (
                isPaused ? <button onClick={handlePause}>Pause</button> :
                  <button onClick={handleResume}>Resume</button>
              )
          }
          <button onClick={handleReset} disabled={!isActive}>Reset</button>
        </div>
      </div>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Let's clean up our code

I have realized we can extract our state and methods to a custom hook. This will make our code clean and reusable.

useTimer hook

In src folder, I have created one more folder hook and within hook I created a file useTimer.js

useTimer hook returns our state and all four functions. Now we can use it wherever we want in our app.

import { useState, useRef } from 'react';

const useTimer = (initialState = 0) => {
  const [timer, setTimer] = useState(initialState)
  const [isActive, setIsActive] = useState(false)
  const [isPaused, setIsPaused] = useState(false)
  const countRef = useRef(null)

  const handleStart = () => {
    setIsActive(true)
    setIsPaused(true)
    countRef.current = setInterval(() => {
      setTimer((timer) => timer + 1)
    }, 1000)
  }

  const handlePause = () => {
    clearInterval(countRef.current)
    setIsPaused(false)
  }

  const handleResume = () => {
    setIsPaused(true)
    countRef.current = setInterval(() => {
      setTimer((timer) => timer + 1)
    }, 1000)
  }

  const handleReset = () => {
    clearInterval(countRef.current)
    setIsActive(false)
    setIsPaused(false)
    setTimer(0)
  }

  return { timer, isActive, isPaused, handleStart, handlePause, handleResume, handleReset }
}

export default useTimer
Enter fullscreen mode Exit fullscreen mode

utils

We can make our code cleaner by writing our vanilla javascript functions into utils folder.

For that, within src I created utils folder, and inside utils I created index.js file.

export const formatTime = (timer) => {
  const getSeconds = `0${(timer % 60)}`.slice(-2)
  const minutes = `${Math.floor(timer / 60)}`
  const getMinutes = `0${minutes % 60}`.slice(-2)
  const getHours = `0${Math.floor(timer / 3600)}`.slice(-2)

  return `${getHours} : ${getMinutes} : ${getSeconds}`
}
Enter fullscreen mode Exit fullscreen mode

Timer.js

I copied code from App.js to Timer.js and render Timer.js inside App.js

This is how our folder structure will look like
Alt Text

import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faClock } from '@fortawesome/free-regular-svg-icons'

import useTimer from '../hooks/useTimer';
import { formatTime } from '../utils';

const element = <FontAwesomeIcon icon={faClock} />

const Timer = () => {
  const { timer, isActive, isPaused, handleStart, handlePause, handleResume, handleReset } = useTimer(0)

  return (
    <div className="app">
      <h3>React Stopwatch {element}</h3>
      <div className='stopwatch-card'>
        <p>{formatTime(timer)}</p>
        <div className='buttons'>
          {
            !isActive && !isPaused ?
              <button onClick={handleStart}>Start</button>
              : (
                isPaused ? <button onClick={handlePause}>Pause</button> :
                  <button onClick={handleResume}>Resume</button>
              )
          }
          <button onClick={handleReset} disabled={!isActive}>Reset</button>
        </div>
      </div>
    </div>
  );
}

export default Timer;
Enter fullscreen mode Exit fullscreen mode

Doesn't it look cleaner now?

CSS

@import url("https://fonts.googleapis.com/css2?family=Quicksand:wght@500&display=swap");

body {
  margin: 0;
  font-family: "Quicksand", sans-serif;
  background-color: #eceff1;
  color: #010b40;
}

.app {
  background-color: #0e4d92;
  margin: 0 auto;
  width: 300px;
  height: 200px;
  position: relative;
  border-radius: 10px;
}

h3 {
  color: white;
  text-align: center;
  padding-top: 8px;
  letter-spacing: 1.2px;
  font-weight: 500;
}

p {
  font-size: 28px;
}

.stopwatch-card {
  position: absolute;
  text-align: center;
  background-color: white;
  box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2);
  width: 325px;
  height: 130px;
  top: 110px;
  left: 50%;
  transform: translate(-50%, -50%);
}

button {
  outline: none;
  background: transparent;
  border: 1px solid blue;
  padding: 5px 10px;
  border-radius: 7px;
  color: blue;
  cursor: pointer;
}

.buttons {
  display: flex;
  justify-content: space-evenly;
  width: 150px;
  margin: 0 auto;
  margin-top: 5px;
}
Enter fullscreen mode Exit fullscreen mode

I want a little feedback if you would like to read the next article with typescript?

Since typescript is evolving and startups are preferring those who can type javascript with typescript.

In next part, we will transform this app to the Pomodoro clock.

Codepen Demo

💖 💪 🙅 🚩
abdulbasit313
Abdul Basit

Posted on July 19, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related