Updated August 3, 2022
Build a Stop Watch Hook that Works Even When the App is Quit
Previously we built a custom hook to power a stop watch.
The problem with that implementation is that it will only work as long as the app is active/running in the background. If the app is quit then the timer stops.
Today we'll be upgrading that hook to work even if the app is quit for months. When you open it back up and, so long as the timer was started, it will give you the elapsed time since you started it.
The key to all of this is the @react-native-async-storage/async-storage
package which will allow us to persist data to disk.
Make sure you install the library before proceeding.
Starting Code
Below you can see the code we'll be starting with. To learn the why behind it you can read the previous tutorial walking you through it step by step.
// hooks/useStopWatch.ts
import { useState, useRef, useEffect } from "react"
export type LapData = {
time: string
lap: number
}
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 [laps, setLaps] = useState<number[]>([])
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)
}
const reset = () => {
setIsRunning(false)
setStartTime(0)
setTimeWhenLastStopped(0)
setTime(0)
setLaps([])
}
const lap = () => {
setLaps(laps => [time, ...laps])
}
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]) : formatMs(time),
hasStarted: time > 0,
slowestLapTime: formatMs(slowestLapTime || 0),
fastestLapTime: formatMs(fastestLapTime || 0),
}
}
Persisting Data
The first thing we'll need to do is to persist (save) data to AsyncStorage. For our use case we'll want to store the following pieces of state
- timeWhenLastStopped
- isRunning
- startTime
- laps
Note: Each piece of data needs to be stored as a string in AsyncStorage.
// hooks/useStopWatch.ts
import { useState, useRef, useEffect } from "react"
import AsyncStorage from "@react-native-async-storage/async-storage"
export type LapData = {
time: string
lap: number
}
const padStart = (num: number) => {
/* ... */
}
const formatMs = (milliseconds: number) => {
/* ... */
}
const ASYNC_KEYS = {
timeWhenLastStopped: "useStopWatch::timeWhenLastStopped",
isRunning: "useStopWatch::isRunning",
startTime: "useStopWatch::startTime",
laps: "useStopWatch::laps",
}
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(() => {
// persist the latest data to async storage to be used later, if needed
const persist = async () => {
try {
await AsyncStorage.multiSet([
[ASYNC_KEYS.timeWhenLastStopped, timeWhenLastStopped.toString()],
[ASYNC_KEYS.isRunning, isRunning.toString()],
[ASYNC_KEYS.startTime, startTime.toString()],
[ASYNC_KEYS.laps, JSON.stringify(laps)],
])
} catch (e) {
console.log("error persisting data")
}
}
persist()
}, [timeWhenLastStopped, isRunning, startTime, laps])
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) => {
/* ... */
})
return {
/* ... */
}
}
In the above code I've gone ahead and used a useEffect
to run any time one of our target pieces of state changes (by adding each piece of state as a dependency) and then leverages AsyncStorage's multiSet function to save all data at one time.
I've pulled the keys I use to reference different pieces of data into an object since we'll need the same keys to pull the data from AsyncStorage momentarily.
Loading Data from AsyncStorage
Now we need to actually load the data from AsyncStorage when the hook is first initialized.
To do this I'll once again use the useEffect
hook but without any dependencies (by passing an empty array of dependencies). That way it will only run the first time the component calling this hook is mounted.
// hooks/useStopWatch.ts
import { useState, useRef, useEffect } from "react"
import AsyncStorage from "@react-native-async-storage/async-storage"
export type LapData = {
time: string
lap: number
}
const padStart = (num: number) => {
/* ... */
}
const formatMs = (milliseconds: number) => {
/* ... */
}
const ASYNC_KEYS = {
timeWhenLastStopped: "useStopWatch::timeWhenLastStopped",
isRunning: "useStopWatch::isRunning",
startTime: "useStopWatch::startTime",
laps: "useStopWatch::laps",
}
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(() => {
// load data from async storage in case app was quit
const loadData = async () => {
try {
const persistedValues = await AsyncStorage.multiGet([
ASYNC_KEYS.timeWhenLastStopped,
ASYNC_KEYS.isRunning,
ASYNC_KEYS.startTime,
ASYNC_KEYS.laps,
])
const [
persistedTimeWhenLastStopped,
persistedIsRunning,
persistedStartTime,
persistedLaps,
] = persistedValues
setTimeWhenLastStopped(
persistedTimeWhenLastStopped[1]
? parseInt(persistedTimeWhenLastStopped[1])
: 0
)
setIsRunning(persistedIsRunning[1] === "true")
setStartTime(
persistedStartTime[1] ? parseInt(persistedStartTime[1]) : 0
)
setLaps(persistedLaps[1] ? JSON.parse(persistedLaps[1]) : [])
} catch (e) {
console.log("error loading persisted data", e)
}
}
loadData()
}, [])
useEffect(() => {
// persist the latest data to async storage to be used later, if needed
/* ... */
}, [timeWhenLastStopped, isRunning, startTime, laps])
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) => {
/* ... */
})
return {
/* ... */
}
}
This code is a little messy just do to the nature of the multiGet
API.
When using multiGet
the response looks like this
[
["useStopWatch::timeWhenLastStopped", "1000"],
["useStopWatch::isRunning", "false"],
["useStopWatch::startTime", "0"],
["useStopWatch::laps", "[]"],
]
Thus the example[1]
you see all over the place. It's just so that we access the value for that property.
Once we've pulled the value from AsyncStorage we need to convert it to the correct type for that piece of state or set a default value if none existed in AsyncStorage.
Waiting for Data to Load
You may think we're done but if you try to use the app right now you'll see that, when you refresh the app, everything just goes to the default values.
That's because the hook that is persisting the data can run before the data is pulled off of AsyncStorage, thus overriding it and setting it to the default values.
So we need to wait for our data to be loaded from AsyncStorage before persisting anything new. We'll add a new piece of state, dataLoaded
, to handle that. Look for // NEW LINE
in the code below to see what has been added.
// hooks/useStopWatch.ts
import { useState, useRef, useEffect } from "react"
import AsyncStorage from "@react-native-async-storage/async-storage"
export type LapData = {
time: string
lap: number
}
const padStart = (num: number) => {
/* ... */
}
const formatMs = (milliseconds: number) => {
/* ... */
}
const ASYNC_KEYS = {
timeWhenLastStopped: "useStopWatch::timeWhenLastStopped",
isRunning: "useStopWatch::isRunning",
startTime: "useStopWatch::startTime",
laps: "useStopWatch::laps",
}
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 [dataLoaded, setDataLoaded] = useState(false)
const interval = useRef<ReturnType<typeof setInterval>>()
useEffect(() => {
// load data from async storage in case app was quit
const loadData = async () => {
try {
const persistedValues = await AsyncStorage.multiGet([
ASYNC_KEYS.timeWhenLastStopped,
ASYNC_KEYS.isRunning,
ASYNC_KEYS.startTime,
ASYNC_KEYS.laps,
])
const [
persistedTimeWhenLastStopped,
persistedIsRunning,
persistedStartTime,
persistedLaps,
] = persistedValues
setTimeWhenLastStopped(
persistedTimeWhenLastStopped[1]
? parseInt(persistedTimeWhenLastStopped[1])
: 0
)
setIsRunning(persistedIsRunning[1] === "true")
setStartTime(
persistedStartTime[1] ? parseInt(persistedStartTime[1]) : 0
)
setLaps(persistedLaps[1] ? JSON.parse(persistedLaps[1]) : [])
setDataLoaded(true) // NEW LINE
} catch (e) {
console.log("error loading persisted data", e)
setDataLoaded(true) // NEW LINE
}
}
loadData()
}, [])
useEffect(() => {
// persist the latest data to async storage to be used later, if needed
const persist = async () => {
try {
await AsyncStorage.multiSet([
[ASYNC_KEYS.timeWhenLastStopped, timeWhenLastStopped.toString()],
[ASYNC_KEYS.isRunning, isRunning.toString()],
[ASYNC_KEYS.startTime, startTime.toString()],
[ASYNC_KEYS.laps, JSON.stringify(laps)],
])
} catch (e) {
console.log("error persisting data")
}
}
// NEW LINE
if (dataLoaded) {
persist()
}
}, [timeWhenLastStopped, isRunning, startTime, laps, dataLoaded])
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) => {
/* ... */
})
return {
start,
stop,
reset,
lap,
isRunning,
time: formatMs(time),
laps: formattedLapData,
currentLapTime: laps[0] ? formatMs(time - laps[0]) : formatMs(time),
hasStarted: time > 0,
slowestLapTime: formatMs(slowestLapTime || 0),
fastestLapTime: formatMs(fastestLapTime || 0),
dataLoaded,
}
}
You can see a few changes above
- Once we successfully or unsuccessfully load data from AsyncStorage we set
dataLoaded
to true dataLoaded
is added as a dependency of the hook that stores data in AsyncStorage- We check if
dataLoaded
is true before calling thepersist()
function dataLoaded
is returned from the hook so we can use that in our UI
Avoiding a Flash in the UI
Right now if you were to run the app everything would work perfectly but the user would briefly see 00:00.00
when they open the app, even if a timer has been running.
We can avoid that by using dataLoaded
in the component and returning null until everything has been loaded.
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 {
time,
isRunning,
start,
stop,
reset,
lap,
laps,
currentLapTime,
hasStarted,
slowestLapTime,
fastestLapTime,
dataLoaded,
} = useStopWatch();
if (!dataLoaded) {
return null;
}
return (
/* ... */
);
};
const styles = StyleSheet.create({
/* ... */
});
export default StopWatch;
Now the timer will run forever! You can view the final code on Github