DEV Community

Famitha M A
Famitha M A

Posted on Originally published at fami-blog.hashnode.dev

How to Build a Fitness Wearable App with React Native (HealthKit, Health Connect, BLE)

TL;DR

  • Use Expo with a dev build. Every lib you need ships a config plugin.
  • iOS health data: react-native-health. Android: react-native-health-connect (Google Fit's APIs are deprecated).
  • BLE: react-native-ble-plx. Standard heart-rate straps speak GATT service 0x180D.
  • Never scan continuously, and model reconnect as a state machine.
  • Store sessions on-device first (SQLite), sync to the cloud later.
  • Android 14+ needs a health foreground service to keep BLE alive with the screen off.

Most "fitness app in React Native" tutorials stop at a workout list and a chart. The fun (and painful) part starts when your phone has to talk to a device that isn't the phone. Here's how I'd build a real wearable companion app, in order.

The stack

Layer Library
Runtime Expo (recent SDK) + config plugins
Router expo-router
Health (iOS) react-native-health
Health (Android) react-native-health-connect
BLE react-native-ble-plx
State Zustand or Redux Toolkit
Charts victory-native (Skia)
Backend Supabase (optional)
Auth expo-auth-session + Sign in with Apple

Hot take: bare React Native isn't worth it here anymore. Heads up though, HealthKit and BLE won't run in Expo Go, so you need a development build:

npx expo install expo-dev-client
eas build --profile development --platform all
Enter fullscreen mode Exit fullscreen mode

If a tutorial tells you to install react-native-google-fit, close the tab.

Step 1: Build the screens before the Bluetooth

Don't start with BLE. It's the interesting part, so it eats every hour you give it, and you end up with heart-rate data and no app to put it in.

Six screens get you to an MVP:

app/
├── (onboarding)/permissions.tsx   # Bluetooth, Location, Health, Notifications
├── (tabs)/
│   ├── index.tsx                  # History
│   ├── pair.tsx                   # Scan, pair, remember device
│   └── settings.tsx               # Units, devices, export
├── session/live.tsx               # Live HR, time, distance, power
└── session/[id].tsx               # Summary: charts, splits, HR zones
Enter fullscreen mode Exit fullscreen mode

You can hand-roll all of that, or generate the scaffold with RapidNative (screens, expo-router tree, Zustand store, Supabase data layer) and spend your time on the wearable protocol instead of another tab bar.

A minimal store to hang everything off:

import { create } from 'zustand';

type ConnState = 'disconnected' | 'reconnecting' | 'connected';

type SessionStore = {
  deviceId: string | null;
  conn: ConnState;
  bpm: number | null;
  setDevice: (id: string) => void;
  setConn: (s: ConnState) => void;
  setBpm: (bpm: number) => void;
};

export const useSession = create<SessionStore>((set) => ({
  deviceId: null,
  conn: 'disconnected',
  bpm: null,
  setDevice: (deviceId) => set({ deviceId }),
  setConn: (conn) => set({ conn }),
  setBpm: (bpm) => set({ bpm }),
}));
Enter fullscreen mode Exit fullscreen mode

Step 2: HealthKit + Health Connect

Users expect workouts to land in Apple Health and Health Connect. Skip it and you'll get the bug report fast.

npx expo install react-native-health react-native-health-connect
Enter fullscreen mode Exit fullscreen mode
{
  "expo": {
    "plugins": [
      [
        "react-native-health",
        {
          "isClinicalDataEnabled": false,
          "healthSharePermission": "Allow $(PRODUCT_NAME) to read your workout and heart rate data",
          "healthUpdatePermission": "Allow $(PRODUCT_NAME) to save your workouts"
        }
      ],
      "react-native-health-connect"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

One hook, both platforms:

import AppleHealthKit, { HealthValue } from 'react-native-health';
import { readRecords } from 'react-native-health-connect';
import { Platform } from 'react-native';

// Assumes initHealthKit() / initialize() + requestPermission() ran at boot.
export async function getTodaySteps(): Promise<number> {
  const start = new Date(new Date().setHours(0, 0, 0, 0));
  const end = new Date();

  if (Platform.OS === 'ios') {
    return new Promise((resolve) => {
      AppleHealthKit.getStepCount(
        { date: end.toISOString() },
        (err, r: HealthValue) => resolve(err ? 0 : r.value),
      );
    });
  }

  const { records } = await readRecords('Steps', {
    timeRangeFilter: { operator: 'between', startTime: start.toISOString(), endTime: end.toISOString() },
  });
  return records.reduce((sum, r) => sum + r.count, 0);
}
Enter fullscreen mode Exit fullscreen mode

Gotcha: iOS has background delivery (HealthKit wakes your app on new samples). Health Connect doesn't, so on Android you poll on resume. Fine for workouts, wrong for continuous monitoring.

Step 3: Talk BLE to the wearable

Standard GATT services cover a lot: heart rate 0x180D, cycling power 0x1818, running speed and cadence 0x1814. Anything else means the vendor's SDK docs.

npx expo install react-native-ble-plx
Enter fullscreen mode Exit fullscreen mode
[
  "react-native-ble-plx",
  {
    "isBackgroundEnabled": true,
    "modes": ["peripheral", "central"],
    "bluetoothAlwaysPermission": "Allow $(PRODUCT_NAME) to connect to your heart rate monitor"
  }
]
Enter fullscreen mode Exit fullscreen mode

Android 12+ also needs BLUETOOTH_SCAN and BLUETOOTH_CONNECT at runtime.

Heart-rate subscription that works with any GATT-compliant strap:

import { BleManager, Characteristic } from 'react-native-ble-plx';
import { Buffer } from 'buffer';

export const manager = new BleManager();
const HR_SERVICE = '0000180d-0000-1000-8000-00805f9b34fb';
const HR_MEASUREMENT = '00002a37-0000-1000-8000-00805f9b34fb';

export function subscribeToHeartRate(
  deviceId: string,
  onBpm: (bpm: number) => void,
): () => void {
  const sub = manager.monitorCharacteristicForDevice(
    deviceId,
    HR_SERVICE,
    HR_MEASUREMENT,
    (error, characteristic: Characteristic | null) => {
      if (error || !characteristic?.value) return;
      const bytes = Buffer.from(characteristic.value, 'base64');
      const is16Bit = (bytes[0] & 0x01) !== 0;
      onBpm(is16Bit ? bytes.readUInt16LE(1) : bytes[1]);
    },
  );
  return () => sub.remove();
}
Enter fullscreen mode Exit fullscreen mode

Two rules that save your ratings:

  1. Never scan continuously. Scan to pair, save the ID, then connect directly next time.
  2. Reconnect is a state machine. Straps drop when users walk away, sweat, or move the phone.

Here's a simple reconnect loop wired to the store:

import { manager, subscribeToHeartRate } from './ble';
import { useSession } from './store';

export async function connectWithRetry(id: string, attempt = 0): Promise<void> {
  const { setConn, setBpm } = useSession.getState();
  setConn(attempt === 0 ? 'disconnected' : 'reconnecting');
  try {
    const device = await manager.connectToDevice(id, { autoConnect: true });
    await device.discoverAllServicesAndCharacteristics();
    setConn('connected');
    const unsub = subscribeToHeartRate(id, setBpm);
    device.onDisconnected(() => {
      unsub();
      connectWithRetry(id, 1);
    });
  } catch {
    const delay = Math.min(30_000, 1_000 * 2 ** attempt);
    setTimeout(() => connectWithRetry(id, attempt + 1), delay);
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Persist first, sync later

Three tiers:

  1. In-memory ring buffer: last 60s of samples for the live chart.
  2. SQLite (expo-sqlite or WatermelonDB): durable session data, batched writes every second.
  3. Cloud (optional): Supabase, synced when online.

A tiny batching writer:

import * as SQLite from 'expo-sqlite';

const db = SQLite.openDatabaseSync('fitness.db');
db.execSync(`CREATE TABLE IF NOT EXISTS samples (
  session_id TEXT, ts INTEGER, bpm INTEGER
);`);

let queue: { sessionId: string; ts: number; bpm: number }[] = [];

export function pushSample(sessionId: string, bpm: number) {
  queue.push({ sessionId, ts: Date.now(), bpm });
}

setInterval(() => {
  if (!queue.length) return;
  const batch = queue;
  queue = [];
  db.withTransactionSync(() => {
    for (const s of batch) {
      db.runSync('INSERT INTO samples VALUES (?, ?, ?)', s.sessionId, s.ts, s.bpm);
    }
  });
}, 1000);
Enter fullscreen mode Exit fullscreen mode

Background modes for long workouts:

{
  "ios": {
    "infoPlist": {
      "UIBackgroundModes": ["bluetooth-central", "location", "fetch"]
    }
  },
  "android": {
    "permissions": ["FOREGROUND_SERVICE", "FOREGROUND_SERVICE_HEALTH"]
  }
}
Enter fullscreen mode Exit fullscreen mode

On Android 14+, no health foreground service means BLE dies when the screen turns off, and you risk a Play Store rejection.

Step 5: Ship

eas build --profile production --platform all
eas submit --platform ios
eas submit --platform android
Enter fullscreen mode Exit fullscreen mode

App Store traps for health apps:

  • HealthKit apps need a privacy policy URL and a usage string for every read/write category.
  • Sign in with Apple is required if you offer third-party logins like Google.

Wrapping up

The UI isn't the hard part. The hard part is the pile of small integrations (permissions, background modes, BLE reconnect, health-store sync), each with its own edge case. Lock the stack, build screens first, treat disconnects as a real state, and store locally before you sync.

What wearable are you building for? Drop a comment with your device and stack, especially if you've fought a vendor-specific BLE protocol. 👇

Top comments (0)