Updated August 3, 2022

Build a Custom React Hook: Stopwatch

Hooks are a powerful way to abstract logic out of a component to make it reusable and to simplify the component. Today we've got an exercise that will help you practice writing your own hooks by creating a stop watch with the ability to track laps.

Demo of Stop Watch

Hook in Use

Before we start building the custom hook lets look at it in use. This is pulled from an open source React Native app developed by React Native School.

We'll cover what each piece means in a moment but take a look at how the custom hook is used in the component. This is the code for the screen in the gif above.

// screens/StopWatch.tsx

import { StyleSheet } from "react-native"

import { Text, View, StatusBar, SafeAreaView } from "components/themed"
import { CircleButton } from "components/buttons"
import { useStopWatch } from "hooks/useStopWatch"
import { LapList } from "components/lists"

const StopWatch = () => {
  const {
    // actions
    start,
    stop,
    reset,
    lap,
    // data
    isRunning,
    time,
    // lap data
    laps,
    currentLapTime,
    hasStarted,
    slowestLapTime,
    fastestLapTime,
  } = useStopWatch()

  return (
    <SafeAreaView style={{ flex: 1 }}>
      <StatusBar />
      <View style={styles.container}>
        <Text style={styles.timeText}>{time}</Text>

        <View style={styles.row}>
          <CircleButton
            onPress={() => {
              isRunning ? lap() : reset()
            }}
          >
            {isRunning ? "Lap" : "Reset"}
          </CircleButton>
          <CircleButton
            onPress={() => {
              isRunning ? stop() : start()
            }}
            color={isRunning ? "red" : "green"}
          >
            {isRunning ? "Stop" : "Start"}
          </CircleButton>
        </View>

        <LapList
          hasStarted={hasStarted}
          currentLapTime={currentLapTime}
          laps={laps}
          fastestLapTime={fastestLapTime}
          slowestLapTime={slowestLapTime}
        />
      </View>
    </SafeAreaView>
  )
}

const styles = StyleSheet.create({
  /* ... */
})

export default StopWatch

Hook Requirements

Let's create a quick list of requirements for this hook.

  • Ability to start and stop the stopwatch
  • Return total time, formatted, as the clock is running
  • Ability to reset the clock time to 0
  • Ability to track a lap
  • Return all lap times
  • Display the updating current lap time
  • Designate the slowest lap time
  • Designate the fastest lap time

Start Functionality

Let's get started building our custom hook. Two resources, from the React docs, that will be helpful to review:

  1. Rules of Hooks
  2. Building Your Own Hooks

Our hook will be called useStopWatch and at this point we just want to add the ability to start the clock and update the elapsed time.

We'll be writing this in TypeScript (learn more about TypeScript in React Native).

// hooks/useStopWatch.ts

import { useState, useRef, useEffect } from "react"

export const useStopWatch = () => {
  const [time, setTime] = useState(0)
  const [isRunning, setIsRunning] = useState(false)
  const [startTime, setStartTime] = useState<number>(0)

  const start = () => {
    setIsRunning(true)
    setStartTime(Date.now())
  }

  return {
    start,

    isRunning,
    time,
  }
}

The first thing we've done is create a function, start, that when called will store the milliseconds since epoch. We'll subtract this value from the current time to figure out how many milliseconds the timer has been running.

We've also got a boolean, isRunning, that allows us to know if the timer is actively running or not.

// hooks/useStopWatch.ts

import { useState, useRef, useEffect } from "react"

export const useStopWatch = () => {
  const [time, setTime] = useState(0)
  const [isRunning, setIsRunning] = useState(false)
  const [startTime, setStartTime] = useState<number>(0)

  const interval = useRef<ReturnType<typeof setInterval>>()

  useEffect(() => {
    if (startTime > 0) {
      interval.current = setInterval(() => {
        setTime(() => Date.now() - startTime)
      }, 1)
    } else {
      if (interval.current) {
        clearInterval(interval.current)
        interval.current = undefined
      }
    }
  }, [startTime])

  const start = () => {
    setIsRunning(true)
    setStartTime(Date.now())
  }

  return {
    start,

    isRunning,
    time,
  }
}

Now we've added a block of code inside the useEffect hook. This has a dependency on the startTime state so whenever that changes this block will re-run.

If the startTime is 0 then we clear our interval, which we're tracking via useRef (more info on the useRef hook).

If the value of startTime is above 0 then we start an interval that will then subtract the current time from our startTime, giving us the elapsed milliseconds.

The actual delay we give our setInterval doesn't matter (it could be every millisecond, as it is above, or every second). The accuracy of the time isn't dependent on that - all that would adjust is how often the UI updates.

Stop Functionality

Next we'll add the ability to stop/pause the timer. When we stop the timer we're going to reset our startTime state but we need to keep track of how long the timer had been running when it was stopped.We'll add a new piece of state, timeWhenLastStopped, which will keep track of how many milliseconds the timer had been running when it was stopped.

We then take the value of that and add it to the elapsed time of the current timer to figure out the total time.

// hooks/useStopWatch.ts

import { useState, useRef, useEffect } from "react"

export const useStopWatch = () => {
  const [time, setTime] = useState(0)
  const [isRunning, setIsRunning] = useState(false)
  const [startTime, setStartTime] = useState<number>(0)
  const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)

  const interval = useRef<ReturnType<typeof setInterval>>()

  useEffect(() => {
    if (startTime > 0) {
      interval.current = setInterval(() => {
        setTime(() => Date.now() - startTime + timeWhenLastStopped)
      }, 1)
    } else {
      if (interval.current) {
        clearInterval(interval.current)
        interval.current = undefined
      }
    }
  }, [startTime])

  const start = () => {
    setIsRunning(true)
    setStartTime(Date.now())
  }

  const stop = () => {
    setIsRunning(false)
    setStartTime(0)
    setTimeWhenLastStopped(time)
  }

  return {
    start,
    stop,

    isRunning,
    time,
  }
}

Formatted Time

Right now the time is stored in milliseconds. As a human this isn't very easy to read so we'll go ahead and format it into hours (if applicable), minutes, seconds, and milliseconds. Additionally, we'll ensure that the number is at least two digits with the padStart function.

We'll then use the formatMs function to format the time that we return from the hook.

// hooks/useStopWatch.ts

import { useState, useRef, useEffect } from "react"

const padStart = (num: number) => {
  return num.toString().padStart(2, "0")
}

const formatMs = (milliseconds: number) => {
  let seconds = Math.floor(milliseconds / 1000)
  let minutes = Math.floor(seconds / 60)
  let hours = Math.floor(minutes / 60)

  // using the modulus operator gets the remainder if the time roles over
  // we don't do this for hours because we want them to rollover
  // seconds = 81 -> minutes = 1, seconds = 21.
  // 60 minutes in an hour, 60 seconds in a minute, 1000 milliseconds in a second.
  minutes = minutes % 60
  seconds = seconds % 60
  // divide the milliseconds by 10 to get the tenths of a second. 543 -> 54
  const ms = Math.floor((milliseconds % 1000) / 10)

  let str = `${padStart(minutes)}:${padStart(seconds)}.${padStart(ms)}`

  if (hours > 0) {
    str = `${padStart(hours)}:${str}`
  }

  return str
}

export const useStopWatch = () => {
  const [time, setTime] = useState(0)
  const [isRunning, setIsRunning] = useState(false)
  const [startTime, setStartTime] = useState<number>(0)
  const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)

  const interval = useRef<ReturnType<typeof setInterval>>()

  useEffect(() => {
    /* ... */
  }, [startTime])

  const start = () => {
    /* ... */
  }

  const stop = () => {
    /* ... */
  }

  return {
    start,
    stop,

    isRunning,
    time: formatMs(time),
  }
}

Since the time will be rapidly changing it's important that the text has a fixed width so the layout isn't constantly shifting. We've covered how to configure fixed width text in React Native in another article.

Reset Clock

Now that the start and stop functions are working we also want to be able to reset the timer. The only difference between this and stopping the timer is that we'll reset the time to 0 as well as set timeWhenLastStopped to 0.

// hooks/useStopWatch.ts

import { useState, useRef, useEffect } from "react"

const padStart = (num: number) => {
  /* ... */
}

const formatMs = (milliseconds: number) => {
  /* ... */
}

export const useStopWatch = () => {
  const [time, setTime] = useState(0)
  const [isRunning, setIsRunning] = useState(false)
  const [startTime, setStartTime] = useState<number>(0)
  const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)

  const interval = useRef<ReturnType<typeof setInterval>>()

  useEffect(() => {
    /* ... */
  }, [startTime])

  const start = () => {
    /* ... */
  }

  const stop = () => {
    /* ... */
  }

  const reset = () => {
    setIsRunning(false)
    setStartTime(0)
    setTimeWhenLastStopped(0)
    setTime(0)
  }

  return {
    start,
    stop,
    reset,

    isRunning,
    time: formatMs(time),
  }
}

Track a Lap

The next feature is tracking laps as they occur. We'll do this by adding a piece of state that is an array of numbers. To track a lap we'll add the current time, when the lap function is called, to the array.

Take note of the change in the reset function as well. We reset the laps state to an empty array when that function is called.

// hooks/useStopWatch.ts

import { useState, useRef, useEffect } from "react"

const padStart = (num: number) => {
  /* ... */
}

const formatMs = (milliseconds: number) => {
  /* ... */
}

export const useStopWatch = () => {
  const [time, setTime] = useState(0)
  const [isRunning, setIsRunning] = useState(false)
  const [startTime, setStartTime] = useState<number>(0)
  const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)
  const [laps, setLaps] = useState<number[]>([])

  const interval = useRef<ReturnType<typeof setInterval>>()

  useEffect(() => {
    /* ... */
  }, [startTime])

  const start = () => {
    /* ... */
  }

  const stop = () => {
    /* ... */
  }

  const reset = () => {
    setIsRunning(false)
    setStartTime(0)
    setTimeWhenLastStopped(0)
    setTime(0)
    setLaps([]) // NEW LINE
  }

  const lap = () => {
    setLaps(laps => [time, ...laps])
  }

  return {
    start,
    stop,
    reset,
    lap,

    isRunning,
    time: formatMs(time),
  }
}

Display All Lap Times

Then, to display the lap times, we'll need to do a bit of math and formatting.

First we need to determine the lap time by taking the current lap's stop time and subtracting the previous lap's stop time. This gives you the actual lap time.

Then we need to determine which lap that time is for. Since the newest lap is added to the start of the array we subtract the index from the length of our laps array.

For example.

["a", "b", "c", "d", "e"] // length === 5
// Lap 5 ('a'). index = 0, 5 - 0.
// Lap 4 ('b'). index = 1, 5 - 1.
// hooks/useStopWatch.ts

import { useState, useRef, useEffect } from "react"

export type LapData = {
  time: string
  lap: number
}

const padStart = (num: number) => {
  /* ... */
}

const formatMs = (milliseconds: number) => {
  /* ... */
}

export const useStopWatch = () => {
  const [time, setTime] = useState(0)
  const [isRunning, setIsRunning] = useState(false)
  const [startTime, setStartTime] = useState<number>(0)
  const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)
  const [laps, setLaps] = useState<number[]>([])

  const interval = useRef<ReturnType<typeof setInterval>>()

  useEffect(() => {
    /* ... */
  }, [startTime])

  const start = () => {
    /* ... */
  }

  const stop = () => {
    /* ... */
  }

  const reset = () => {
    /* ... */
  }

  const lap = () => {
    /* ... */
  }

  const formattedLapData: LapData[] = laps.map((l, index) => {
    const previousLap = laps[index + 1] || 0
    const lapTime = l - previousLap

    return {
      time: formatMs(lapTime),
      lap: laps.length - index,
    }
  })

  return {
    start,
    stop,
    reset,
    lap,

    isRunning,
    time: formatMs(time),

    laps: formattedLapData,
  }
}

Display the Current Lap Time

If you look back to the gif at the beginning you'll notice that we have not only the total time that is updating but also the current lap time. This lap time is how much time has elapsed since the previous lap.

To calculate that we need to subtract the last lap time from the current time or, if this is the first lap, the time and the currentLapTime will be the same.

// hooks/useStopWatch.ts

import { useState, useRef, useEffect } from "react"

export type LapData = {
  /* ... */
}

const padStart = (num: number) => {
  /* ... */
}

const formatMs = (milliseconds: number) => {
  /* ... */
}

export const useStopWatch = () => {
  const [time, setTime] = useState(0)
  const [isRunning, setIsRunning] = useState(false)
  const [startTime, setStartTime] = useState<number>(0)
  const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)
  const [laps, setLaps] = useState<number[]>([])

  const interval = useRef<ReturnType<typeof setInterval>>()

  useEffect(() => {
    /* ... */
  }, [startTime])

  const start = () => {
    /* ... */
  }

  const stop = () => {
    /* ... */
  }

  const reset = () => {
    /* ... */
  }

  const lap = () => {
    /* ... */
  }

  const formattedLapData: LapData[] = laps.map((l, index) => {
    /* ... */
  })

  return {
    start,
    stop,
    reset,
    lap,

    isRunning,
    time: formatMs(time),

    laps: formattedLapData,
    currentLapTime: laps[0] ? formatMs(time - laps[0]) : formatMs(time),
    hasStarted: time > 0,
  }
}

We also add the hasStarted boolean. This is used to determine if the laps information should be displayed.

Designate Slowest Lap Time

We're in the final stretch! The second to last thing we'll do is figure out the slowest lap time. We can accomplish this at the same time that we calculate individual lap times.

All we have to do is determine if the lap time we're currently looking at is slower than the previous slowest. If we don't have a slowest lap time yet then the one we're looking at is it!

// hooks/useStopWatch.ts

import { useState, useRef, useEffect } from "react"

export type LapData = {
  /* ... */
}

const padStart = (num: number) => {
  /* ... */
}

const formatMs = (milliseconds: number) => {
  /* ... */
}

export const useStopWatch = () => {
  const [time, setTime] = useState(0)
  const [isRunning, setIsRunning] = useState(false)
  const [startTime, setStartTime] = useState<number>(0)
  const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)
  const [laps, setLaps] = useState<number[]>([])

  const interval = useRef<ReturnType<typeof setInterval>>()

  useEffect(() => {
    /* ... */
  }, [startTime])

  const start = () => {
    /* ... */
  }

  const stop = () => {
    /* ... */
  }

  const reset = () => {
    /* ... */
  }

  const lap = () => {
    /* ... */
  }

  let slowestLapTime: number | undefined
  const formattedLapData: LapData[] = laps.map((l, index) => {
    const previousLap = laps[index + 1] || 0
    const lapTime = l - previousLap

    if (!slowestLapTime || lapTime > slowestLapTime) {
      slowestLapTime = lapTime
    }

    return {
      time: formatMs(lapTime),
      lap: laps.length - index,
    }
  })

  return {
    start,
    stop,
    reset,
    lap,

    isRunning,
    time: formatMs(time),

    laps: formattedLapData,
    currentLapTime: laps[0] ? formatMs(time - laps[0] || 0) : formatMs(time),
    hasStarted: time > 0,
    slowestLapTime: formatMs(slowestLapTime || 0),
  }
}

Designate the Fastest Lap Time

This is the exact opposite of the slowest lap time.

// hooks/useStopWatch.ts

import { useState, useRef, useEffect } from "react"

export type LapData = {
  /* ... */
}

const padStart = (num: number) => {
  /* ... */
}

const formatMs = (milliseconds: number) => {
  /* ... */
}

export const useStopWatch = () => {
  const [time, setTime] = useState(0)
  const [isRunning, setIsRunning] = useState(false)
  const [startTime, setStartTime] = useState<number>(0)
  const [timeWhenLastStopped, setTimeWhenLastStopped] = useState<number>(0)
  const [laps, setLaps] = useState<number[]>([])

  const interval = useRef<ReturnType<typeof setInterval>>()

  useEffect(() => {
    /* ... */
  }, [startTime])

  const start = () => {
    /* ... */
  }

  const stop = () => {
    /* ... */
  }

  const reset = () => {
    /* ... */
  }

  const lap = () => {
    /* ... */
  }

  let slowestLapTime: number | undefined
  let fastestLapTime: number | undefined

  const formattedLapData: LapData[] = laps.map((l, index) => {
    const previousLap = laps[index + 1] || 0
    const lapTime = l - previousLap

    if (!slowestLapTime || lapTime > slowestLapTime) {
      slowestLapTime = lapTime
    }

    if (!fastestLapTime || lapTime < fastestLapTime) {
      fastestLapTime = lapTime
    }

    return {
      time: formatMs(lapTime),
      lap: laps.length - index,
    }
  })

  return {
    start,
    stop,
    reset,
    lap,

    isRunning,
    time: formatMs(time),

    laps: formattedLapData,
    currentLapTime: laps[0] ? formatMs(time - laps[0] || 0) : formatMs(time),
    hasStarted: time > 0,
    slowestLapTime: formatMs(slowestLapTime || 0),
    fastestLapTime: formatMs(fastestLapTime || 0),
  }
}

And there you have a functioning custom stop watch hook! You can see it in use in the open source Clock app (one of many open source React Native apps we have).

Demo of Stop Watch

Next Steps

Now, we've gone through all this work but this implementation has a problem.

What happens if our app is quit? The "real" Clock app will continue to count the time but ours will reset.

How would you fix this issue? Learn how I did so in part 2 of this tutorial.

Let us know on Twitter.

React Native School Logo

React Native School

Want to further level up as a React Native developer? Join React Native School! You'll get access to all of our courses and our private Slack community.

Learn More