Build a Custom React Hook: Stopwatch
Tags: reactnative, hooks, reacthooks
Published: 2022-07-19 16:37:49

---
title: "Build a Custom React Hook: Stopwatch"
published: true
description: 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.
tags: reactnative, hooks, reacthooks
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6hirsmwcu4ah435suk94.jpg
# Use a ratio of 100:42 for best results.
canonical_url: https://www.reactnativeschool.com/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](https://media.giphy.com/media/9XA5BOg7yfQ3bIZDKl/giphy.gif)

_This post was [originally published on React Native School](https://www.reactnativeschool.com/build-a-custom-react-hook-stopwatch). Be sure to visit for exclusive React Native tricks, tips, and tutorials!_


## 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](https://github.com/ReactNativeSchool/expo-clock-app/blob/main/src/screens/StopWatch.tsx#L9) 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.

```tsx
// 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](https://reactjs.org/docs/hooks-rules.html)
2. [Building Your Own Hooks](https://reactjs.org/docs/hooks-custom.html)

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](https://www.reactnativeschool.com/topic/typescript)).

```tsx
// 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](https://www.maketecheasier.com/what-is-epoch-time/). 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.

```tsx
// 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](https://reactjs.org/docs/hooks-reference.html#useref)).

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 milllisecond, 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.

```tsx
// 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 atleast two digits with the `padStart` function.

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

```tsx
// 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](https://www.reactnativeschool.com/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`.

```tsx
// 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.

```tsx
// 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.

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

```tsx
// 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.

```tsx
// 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!

```tsx
// 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.

```tsx
// 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](https://github.com/ReactNativeSchool/expo-clock-app/blob/main/src/screens/StopWatch.tsx#L9) (one of many open source React Native apps we have).

![Demo of Stop Watch](https://media.giphy.com/media/9XA5BOg7yfQ3bIZDKl/giphy.gif)

## 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](https://www.reactnativeschool.com/build-a-stop-watch-hook-that-works-even-when-the-app-is-quit).

[Let us know on Twitter](https://twitter.com/learn_rn).