Building a habit tracking app is a fun challenge. For my latest project, I decided to gamify the experience with a UI inspired by dating apps, swipe left to skip a habit, swipe right to complete it.
It was all smooth sailing until I implemented "Duration Habits" (e.g., "Medidate for 20 minutes"). The UI looked great, but I immediately hit a classic React Native roadblock:
The moment the user locked their phone or switched to another app, the Javascript thread paused, and my timer stopped dead.
For a meditation or workout timer, this is broken functionality. The user expects the timer to alert them when they are done, regardless of whether the app is open.
In this post, I'll walk through how I solved this using Android Foreground Services via react-native-background-actions, and crucially, how I synchronized that background process with my modern state management solution, Zustand.
The Challenge: React Native vs. The OS
Mobile operating systems are aggresive about saving battery. When an app moves to the background, iOS and Android will suspend its Javascript execution thread almost immediately.
To keep code running, we need a special permit from the OS. On Android, this called a Foreground Service. It tells the OS, "Hey, I'm doing something important that the user is aware of, please don't kill me." This requires showing a persistent notification in the status bar so the user knows the app is still active.
The Stack
- React Native Expo (Dev Client) or bare workflow.
- State Management: Zustand (This is important later).
- The Hero Library: react-native-background-actions
Step 1: Setup and Configuration
first, get the library installed:
npm install react-native-background-actions
Android Configuration is Mandatory
You cannot skip this step. You need to declare the necessary permission and the service itself in your android/app/src/main/AndroidManifest.xml file. Without this, the app will crash when you try to start the background task.
Open AndroidManifest.xml and add these lines inside the <manifest> tag and <application> tag respectively:
<manifest ...>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application ...>
<service android:name="com.asterinet.react.bgactions.RNBackgroundActionsTask" />
</application>
</manifest>
Step 2: The State Synchronization Problem
This is where most developers get stuck.
A background task created by this library runs in a Javascript context that is separate from your React Component lifecyle.
Crucial realization: You cannot use standard React Hooks like useState, useEffect, or custom Zustand hooks like useHabitStore() inside the background task loop. They won't update or react as you expect.
The Solution: Imperative State Access
Since we are using Zustand, we have an escape hatch. Instead of using reactive hook inside the background loop, we will access the store directly and imperatively using useHabitStore.getState().
Step 3: The Background Task Function
This is the heart of the operation. This function defines what happens while the app is in the background. It's essentially an infinite loop that only breaks when the timer finishes or the user pauses it in the UI.
Here is the real-world code adapted for a timer:
import BackgroundService from 'react-native-background-actions';
import { useHabitStore } from './store/habitStore'; // Import your Zustand store directly
// Helper sleep function to prevent CPU overload
const sleep = (time: number) => new Promise<void>((resolve) => setTimeout(() => resolve(), time));
// The task function must be defined outside of your React components
export const timerBackgroundTask = async (taskDataArguments?: {
targetSeconds?: number;
habitName?: string;
}): Promise<void> => {
const target = taskDataArguments?.targetSeconds || 0;
const habitName = taskDataArguments?.habitName || "Timer";
await new Promise(async (resolve) => {
// The loop runs as long as the service is active
while (BackgroundService.isRunning()) {
// 1. IMPERATIVE STATE ACCESS (No Hooks allowed here!)
const state = useHabitStore.getState();
// 2. Check Logic Stop condition
// If the user paused manually in the UI, break the loop.
if (!state.timerIsRunning || state.timerIsCompleted) {
break;
}
// 3. Timer Logic utilizing Date.now() for robustness
const currentTime = Date.now();
const startTime = state.timerStartTime;
// Initialize start time if missing
if (!startTime) {
useHabitStore.getState().setTimerStartTime(currentTime);
await sleep(1000);
continue;
}
const elapsed = Math.floor((currentTime - startTime) / 1000);
// 4. Check if Target Reached
if (target > 0 && elapsed >= target) {
// Update State via getState()
useHabitStore.getState().setTimerIsCompleted(true);
useHabitStore.getState().setTimerIsRunning(false);
useHabitStore.getState().setTimerTimeElapsed(target);
// Update Final Notification
await BackgroundService.updateNotification({
taskTitle: habitName,
taskDesc: "Timer finished!",
progressBar: { // Fill the bar completely
max: target,
value: target,
indeterminate: false,
},
});
// Break the loop to finish the task naturally
break;
} else {
// Standard Tick Update
useHabitStore.getState().setTimerTimeElapsed(elapsed);
// Update the native notification with a progress bar!
const remaining = Math.max(0, target - elapsed);
await BackgroundService.updateNotification({
taskTitle: habitName,
// Assuming a helper function formatTime exists
taskDesc: `Remaining: ${formatTime(remaining)}`,
progressBar: {
max: target,
value: elapsed,
indeterminate: false,
},
});
}
// 5. IMPORTANT: Sleep to save battery and CPU
await sleep(1000);
}
// When the loop breaks, the task ends.
resolve(undefined);
});
};
Step 4: Triggering the Service from the UI
Now we need to connect our React Native UI to this background task. When the user taps "Start Timer" on a habit card, the service should spin up. When they tap "Pause", it should tear down.
We use useEffect within our timer component to monitor changes in our Zustand state and react accordingly.
import { useEffect } from 'react';
import BackgroundService from 'react-native-background-actions';
import { useHabitStore } from './store/habitStore';
import { timerBackgroundTask } from './backgroundTasks'; // Import the function above
import { COLORS } from './theme';
// Inside your TimerComponent...
const {
timerIsRunning,
timerIsCompleted,
selectedHabit,
targetSeconds
} = useHabitStore();
useEffect(() => {
const manageBackgroundService = async () => {
// Condition to START: Timer is running, not finished, and a habit is selected
if (timerIsRunning && !timerIsCompleted && selectedHabit) {
// Options for the native notification
const options = {
taskName: "Habit Timer Task",
taskTitle: selectedHabit.name,
taskDesc: "Timer Running...",
taskIcon: {
name: "ic_launcher", // Ensure this icon exists in android/app/src/main/res/mipmap-*
type: "mipmap",
},
color: COLORS.primary,
parameters: { // Pass initial data to the background task arguments
targetSeconds,
habitName: selectedHabit.name,
},
};
try {
// Only start if it's not already running
if (!BackgroundService.isRunning()) {
await BackgroundService.start(timerBackgroundTask, options);
console.log('Background service started successfully');
}
// Optional: else { BackgroundService.updateNotification(...) } if needed just for UI updates
} catch (error) {
console.error("Background Service Start failed:", error);
}
} else {
// Logic to STOP: If timer is paused in UI or completed
if (BackgroundService.isRunning()) {
console.log('Stopping background service...');
await BackgroundService.stop(); // This initiates the teardown of the while loop
}
}
};
manageBackgroundService();
// Re-run this logic whenever the timer state changes
}, [timerIsRunning, timerIsCompleted, selectedHabit, targetSeconds]);
Key Takeaways for Implementation
If you are implementing this in your own app, keep these lessons in mind:
-
State Management Paradox: Remember, the background task is just a standard JS function, not a React component. You cannot use hooks. If you use Redux or Zustand, you must use their imperative APIs (like
store.getState()andstore.dispatch()) to read and write data inside the background loop. -
The Notification Loop: The
BackgroundService.updateNotificationallows you to make the notification feel "alive." Updating the progress bar and timer text every second assures the user that the app hasn't crashed while in their pocket. -
Clean Up is Vital: The
useEffectdependency array is crucial. WhentimerIsRunningflips to false in your UI, you must callBackgroundService.stop(). Otherwise, you'll leave "ghost processes" running that drain battery and annoy users. -
Robuts Timing: Notive in the background task I used
Date.now()subtraction rather than just incrementing a counter likeelapsed++. Using system time differences ensures that even if the OS briefly pauses execution, the timer calculation remains accurate when it wakes back up. This approach provided the robust "set it and forget it" experience my habit tracker needed, matching the sleekness of the swipe-based UI.
for more information about development web or mobile you can visit my website
Top comments (0)