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.
This post was originally published on React Native School. 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 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:
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 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.
// 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.
// 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).
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.
Top comments (0)