How to build a background timer in Expo/React Native

Say you want to build a simple timer in React Native and you’ve decided to use the Expo managed workflow. It’s pretty easy to do this in React.

Image for post
Image for post
Photo by Djim Loic on Unsplash

However, one thing you’ll run into when porting this to React Native is how this behaves when your app is backgrounded (e.g. when you press the Home button on your device). The callback given to setInterval eventually stops being called (on Android this happens as soon as your app is backgrounded). This means that when your app comes back to the foreground, it will have missed a ton of seconds and your timer will be inaccurate.

If you’re willing to eject, then you could probably use this library. But what if you don’t want to eject? This is how I worked around this issue. The code snippets below assume that the timer implementation uses the example code from above.

Nothing’s more persistent than storing things on disk. Let’s use it to store the exact timestamp when the “start” button was pressed. This looks something like this:

const recordStartTime = async () => {
try {
const now = new Date();
await AsyncStorage.setItem("@start_time", now.toISOString());
} catch (err) {
// TODO: handle errors from setItem properly
console.warn(err);
}
};

The idea is that when your app is backgrounded, you can assume that the timer isn’t updated. What you want to do though is to bring your timer up-to-date when your app is foregrounded. To do this, we use the AppState API. We will also use the powerful date-fns package to properly calculate difference between two Date objects. This looks something like this:

import { useEffect } from "react";
import { AppState } from "react-native"
import AsyncStorage from "@react-native-community/async-storage";
import { differenceInSeconds } from "date-fns";
const appState = useRef(AppState.currentState);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
AppState.addEventListener("change", handleAppStateChange);
return () => AppState.removeEventListener("change", handleAppStateChange);
}, []);
const handleAppStateChange = async (nextAppState) => {
if (appState.current.match(/inactive|background/) &&
nextAppState === "active") {
// We just became active again: recalculate elapsed time based
// on what we stored in AsyncStorage when we started.
const elapsed = await getElapsedTime();
// Update the elapsed seconds state
setElapsed(elapsed);
}
appState.current = nextAppState;
};
const getElapsedTime = async () => {
try {
const startTime = await AsyncStorage.getItem("@start_time");
const now = new Date();
return differenceInSeconds(now, Date.parse(startTime));
} catch (err) {
// TODO: handle errors from setItem properly
console.warn(err);
}
};

Although this properly updates the view component when the app is brought to the foreground, this definitely does not mean that anything is happening in the background. If you want to do a background task (e.g. fetch data), there is some API support for this. There are even hacky ways to go about it!

Co-founder & CTO @ AgentRisk. Former infra-tech guy (storage, networks). Startup nerd. Always building cool side-projects. #LongLA

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store