DEV Community

Cover image for Build a Stop Watch Hook that Works Even When the App is Quit
Spencer Carli
Spencer Carli

Posted on • Originally published at reactnativeschool.com

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.

Stop watch resetting when app is refresh

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.

This post was originally published on React Native School.

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),
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {
    /* ... */
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {
    /* ... */
  }
}
Enter fullscreen mode Exit fullscreen mode

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", "[]"],
]
Enter fullscreen mode Exit fullscreen mode

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,
  }
}
Enter fullscreen mode Exit fullscreen mode

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 the persist() 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;
Enter fullscreen mode Exit fullscreen mode

Stop watch that continues counting when app is refreshed

Now the timer will run forever! You can view the final code on Github

Top comments (0)