Prevent React Native Timers from Stopping in Background: A Complete Guide
Source: Dev.to
Building a Habit‑Tracking App with a Background Timer
The challenge:
When the user locks their phone or switches to another app, the JavaScript thread pauses and the timer stops. For a meditation or workout timer this is broken functionality – the user expects the timer to keep running and alert them when it’s done, even if the app isn’t in the foreground.
Solution: Use an Android Foreground Service via react-native-background-actions and synchronize it with a modern state‑management solution – Zustand.
Why a Foreground Service?
Mobile OSes aggressively suspend background JavaScript to save battery. A foreground service tells the OS:
“I’m doing something important that the user knows about – don’t kill me.”
It requires a persistent notification so the user can see that the app is still active.
The Stack
| Component | Reason |
|---|---|
| React Native (Expo Dev Client or bare workflow) | UI & cross‑platform code |
| Zustand | Simple, hook‑free state store (crucial for background access) |
| react-native-background-actions | Handles Android foreground services |
1️⃣ Install the library
npm install react-native-background-actions
2️⃣ Android Manifest configuration
Add the required permissions inside the <manifest> tag and declare the service inside the <application> tag of android/app/src/main/AndroidManifest.xml:
<!-- Add your permissions and service declaration here -->
Tip: This is where most developers get stuck. Forgetting either the permissions or the service declaration will cause a crash when you try to start the background task.
3️⃣ Accessing Zustand from a background thread
Important: You cannot use React hooks (
useState,useEffect, custom hooks likeuseHabitStore()) inside the background task loop. Hooks rely on the React render cycle, which isn’t running in the service.
Zustand gives us an escape hatch: imperative access via store.getState(). This lets the background task read and mutate state directly.
4️⃣ The background task definition
The task function must be defined outside any React component.
// timerBackgroundTask.ts
import BackgroundService from 'react-native-background-actions';
import { useHabitStore } from './store/habitStore'; // Direct import of the Zustand store
// Helper: pause execution without hogging the CPU
const sleep = (time: number) =>
new Promise((resolve) => setTimeout(resolve, time));
/**
* Background timer task.
* @param taskDataArguments Optional data passed when starting the service.
*/
export const timerBackgroundTask = async (
taskDataArguments?: {
targetSeconds?: number;
habitName?: string;
}
): Promise<void> => {
const target = taskDataArguments?.targetSeconds ?? 0;
const habitName = taskDataArguments?.habitName ?? 'Timer';
await new Promise((resolve) => {
// Loop while the foreground service is alive
const loop = async () => {
while (BackgroundService.isRunning()) {
// 1️⃣ IMPERATIVE STATE ACCESS (no hooks!)
const state = useHabitStore.getState();
// 2️⃣ Stop conditions – user paused or timer already finished
if (!state.timerIsRunning || state.timerIsCompleted) {
break;
}
// 3️⃣ Timer logic (robust against app pauses)
const now = Date.now();
const start = state.timerStartTime;
// Initialise start time if it’s missing
if (!start) {
useHabitStore.getState().setTimerStartTime(now);
await sleep(1000);
continue;
}
const elapsed = Math.floor((now - start) / 1000); // seconds
// 4️⃣ Target reached?
if (target > 0 && elapsed >= target) {
// Update Zustand state
useHabitStore.getState().setTimerIsCompleted(true);
useHabitStore.getState().setTimerIsRunning(false);
useHabitStore.getState().setTimerTimeElapsed(target);
// Final notification
await BackgroundService.updateNotification({
taskTitle: habitName,
taskDesc: 'Timer finished!',
progressBar: {
max: target,
value: target,
indeterminate: false,
},
});
// End the loop
break;
} else {
// 5️⃣ Regular tick – update elapsed time & notification
useHabitStore.getState().setTimerTimeElapsed(elapsed);
const remaining = Math.max(0, target - elapsed);
await BackgroundService.updateNotification({
taskTitle: habitName,
taskDesc: `Remaining: ${formatTime(remaining)}`, // helper defined elsewhere
progressBar: {
max: target,
value: elapsed,
indeterminate: false,
},
});
}
// 6️⃣ Sleep 1 s to save battery/CPU
await sleep(1000);
}
// Loop exited – resolve the outer promise
resolve();
};
// Kick‑off the async loop
loop();
});
};
Key points
- Imperative store access (
useHabitStore.getState()) is the only way to read/write state inside the service.- The loop checks
BackgroundService.isRunning()each iteration, so the task stops cleanly when the service is stopped.sleep(1000)prevents the loop from hogging the CPU.
5️⃣ Hooking the UI to the background task
// HabitTimer.tsx
import { useEffect } from 'react';
import BackgroundService from 'react-native-background-actions';
import { useHabitStore } from './store/habitStore';
import { timerBackgroundTask } from './timerBackgroundTask';
// Options for the foreground service notification
const serviceOptions = {
taskName: 'HabitTimer',
taskTitle: 'Habit Timer',
taskDesc: 'Running...',
// Android only – customize the notification appearance
notification: {
channelId: 'habit-timer',
channelName: 'Habit Timer',
color: '#ff0000',
// icon: 'ic_launcher', // optional
},
};
export const HabitTimer = () => {
const {
timerIsRunning,
timerIsCompleted,
timerTargetSeconds,
habitName,
setTimerIsRunning,
setTimerIsCompleted,
setTimerTimeElapsed,
// any other needed actions...
} = useHabitStore();
// Start the foreground service when the timer becomes active
useEffect(() => {
const startService = async () => {
if (timerIsRunning && !timerIsCompleted) {
await BackgroundService.start(timerBackgroundTask, serviceOptions, {
// Pass any data the task needs
targetSeconds: timerTargetSeconds,
habitName,
});
}
};
startService();
// Cleanup – stop the service when the component unmounts or timer stops
return () => {
if (!timerIsRunning || timerIsCompleted) {
BackgroundService.stop();
}
};
}, [timerIsRunning, timerIsCompleted, timerTargetSeconds, habitName]);
// UI rendering (simplified)
return (
<div>
{habitName}
{/* Render timer UI, start/pause buttons, etc. */}
</div>
);
};
Explanation
useEffectwatches the relevant pieces of Zustand state (timerIsRunning,timerIsCompleted, etc.).- When the timer is started, we call
BackgroundService.start(...)with:- the task function (
timerBackgroundTask) - notification options (
serviceOptions) - any arguments the task needs (
targetSeconds,habitName).
- the task function (
- The cleanup function stops the service if the timer is paused or completed, or when the component unmounts.
6️⃣ Helper: Formatting time (optional)
// utils.ts
export const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
};
🎉 Recap
| Step | What you did |
|---|---|
| 1️⃣ Install | Added react-native-background-actions |
| 2️⃣ Manifest | Declared foreground‑service permission & service |
| 3️⃣ Store access | Switched from hooks to store.getState() inside the background loop |
| 4️⃣ Task | Implemented an async loop that updates Zustand, updates the notification, and sleeps 1 s each tick |
| 5️⃣ UI wiring | Used a useEffect to start/stop the foreground service based on Zustand state |
| 6️⃣ Extras | Added a tiny formatTime helper for pretty notifications |
Now the timer keeps running even when the app is backgrounded, the user sees a persistent notification, and the UI stays in sync thanks to Zustand’s imperative API. Happy habit‑building! 🚀
Background Service Integration
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 you need UI‑only updates
} catch (error) {
console.error('Background Service Start failed:', error);
}
} else {
// -------------------------------------------------
// Logic to **STOP**: timer paused in UI or completed
// -------------------------------------------------
if (BackgroundService.isRunning()) {
console.log('Stopping background service...');
await BackgroundService.stop(); // Initiates teardown of the while loop
}
}
};
manageBackgroundService();
// Re‑run this logic whenever the timer state changes
}, [timerIsRunning, timerIsCompleted, selectedHabit, targetSeconds]);
Implementation Tips
-
State‑Management Paradox
The background task is a plain JavaScript function, not a React component.
You cannot use hooks inside it. If you rely on Redux, Zustand, or another store, use their imperative APIs (store.getState(),store.dispatch()) to read/write data from within the loop. -
The Notification Loop
BackgroundService.updateNotificationlets you keep the notification “alive.”
Updating the progress bar and timer text each second reassures the user that the app is still running while it’s in their pocket. -
Clean‑Up Is Vital
TheuseEffectdependency array is crucial. WhentimerIsRunningflips tofalsein the UI, you must callBackgroundService.stop().
Failing to do so leaves “ghost processes” that drain battery and annoy users. -
Robust Timing
Notice that the background task usesDate.now()subtraction rather than a simpleelapsed++counter.
Using system‑time differences guarantees accurate timing even if the OS briefly pauses execution.
This approach gave me the robust “set it and forget it” experience my habit tracker needed, matching the sleekness of the swipe‑based UI.
For more information about web or mobile development, feel free to visit my website.