DEV Community

Cover image for How to Build a Fitness Wearable App with React Native (HealthKit, Health Connect, and BLE)
Mason Roy
Mason Roy

Posted on Originally published at rapidnative.com

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

TL;DR

  • A wearable companion app is a phone app that pairs with a device, streams data from it, persists it, and writes it back to Apple Health / Health Connect. It is not a "log your workouts" app.
  • Stack: Expo SDK 52+, expo-router, react-native-health, react-native-health-connect, react-native-ble-plx, Zustand, expo-sqlite. Don't use bare RN, don't use react-native-google-fit.
  • Design the six screens first, then wire HealthKit/Health Connect, then BLE, then background persistence.
  • Standard GATT heart rate is ~40 lines. Reconnect logic and battery are where you'll actually spend your time.
  • Store on-device first, sync to the cloud opportunistically.

Most "build a fitness app in React Native" tutorials stop at the easy part: a nice UI, a workout list, a chart. The interesting engineering starts once your phone has to talk to a device that isn't the phone — a Whoop, a Garmin, a Polar strap, an Apple Watch. This is the end-to-end version.

What a fitness wearable companion app actually is

It's a phone app whose job is to pair with a physical wearable (watch, chest strap, ring, bike sensor), read health data from it in real time, persist that data, and present it back to the user. Roughly 60–70% of the code is React Native on the phone; the rest is native permissions, Bluetooth adapters, and background delivery bridges to Apple HealthKit and Android Health Connect.

If you've wired a Polar H10 into a running app, or streamed cycling power from an Assioma pedal into a dashboard, that's the shape of app this is about. It's structurally different from a workout logger, and confusing the two is the #1 reason people underestimate the timeline.

The tech stack (and why each piece is non-negotiable)

Layer Library Why
Runtime Expo (SDK 52+), config-plugin workflow Managed native config, EAS Build
Router expo-router v4 File-based routing
Health (iOS) react-native-health HealthKit read/write, background delivery
Health (Android) react-native-health-connect Health Connect (replaced Google Fit in 2025)
BLE react-native-ble-plx GATT client for any BLE peripheral
State Zustand or Redux Toolkit One store
Charts victory-native Skia-backed, doesn't drop frames
Backend (optional) Supabase Postgres + Auth + realtime
Auth expo-auth-session + Sign in with Apple App Store requirement if you offer any other login

Two things worth stressing. Use Expo, not bare React Native — every library above ships an Expo config plugin now, and the friction that used to justify bare has mostly gone. And Health Connect replaced Google Fit's developer API in 2025. If a tutorial tells you to install react-native-google-fit, close the tab.

Step 1: Design the screens before you write code

The mistake is starting with BLE. BLE is the interesting part, so it will eat every hour you give it, and you'll end up with heart-rate data and no app to put it in.

A minimum-viable companion app has six screens:

  1. Onboarding — permissions (Bluetooth, Location, HealthKit / Health Connect, Notifications)
  2. Device pairing — scan, pair, remember
  3. Live session — real-time HR, elapsed time, distance, power
  4. Session summary — charts, splits, HR zones, calories
  5. History — past sessions with quick stats
  6. Profile & settings — units, connected devices, data export

Scaffold those first. They're boring, and that's the point.

Step 2: Wire up HealthKit and Health Connect

Users expect workouts to show up in Apple Health and Health Connect. Not writing there is a bug report you'll get in week one.

npx expo install react-native-health react-native-health-connect
Enter fullscreen mode Exit fullscreen mode

Register the plugins in app.json:

{
  "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

Read today's step count on both platforms with one function:

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

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

What this hides: background delivery on iOS lets HealthKit wake your app when a new sample lands — set it up once at boot and treat it like a push. Android gives you nothing equivalent from Health Connect; you poll on app resume, which is fine for workouts and wrong for continuous monitoring.

Step 3: Talk to a wearable over BLE

This is the real work. Most consumer wearables expose data over BLE using either a standard GATT service (heart rate 0x180D, cycling power 0x1818, running speed and cadence 0x1814) or a vendor protocol you'll reverse-engineer from their SDK docs.

npx expo install react-native-ble-plx
Enter fullscreen mode Exit fullscreen mode

Plugin config (iOS 13+ needs both usage strings; Android 12+ needs BLUETOOTH_SCAN and BLUETOOTH_CONNECT runtime permissions):

[
  "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

Subscribe to a standard heart-rate monitor — works with any GATT-compliant strap on the market:

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

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 flags = bytes[0];
      const is16Bit = (flags & 0x01) !== 0;
      const bpm = is16Bit ? bytes.readUInt16LE(1) : bytes[1];
      onBpm(bpm);
    },
  );
  return () => sub.remove();
}
Enter fullscreen mode Exit fullscreen mode

Two lessons from shipping BLE apps:

  • Never scan continuously. Scanning wakes the radio and kills battery on iOS. Scan for pairing only; once you have the device ID, connectToDevice(id) directly next time.
  • Reconnect is a state machine, not a callback. A worn strap drops every time the user walks out of range or sweats too much. You need an explicit disconnected → reconnecting → connected state that survives foreground/background transitions.

Skip either and your five-star reviews become "app drains my battery" one-stars within a week.

Step 4: Persist, sync, and survive backgrounding

You have a hook streaming BPM. Where does it go? The pattern that works in production is a three-tier store:

  1. In-memory ring buffer — last 60 seconds of raw samples for the live chart. Never persisted.
  2. On-device SQLite (expo-sqlite or WatermelonDB) — the durable copy of every session. Batch writes every second, not per sample.
  3. Cloud (optional) — Supabase or your own Postgres, synced when the network is there.

The on-device layer is non-negotiable. Users run out of cell coverage, and losing a workout to "cloud sync failed" is unforgivable. Store first, sync opportunistically.

For long workouts, enable background modes:

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

On Android 14+, a health-tracking foreground service is required to keep BLE alive with the screen off. Getting this wrong is a Play Store rejection.

Step 5: Ship it

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

Two App Store gotchas for health apps:

  • HealthKit apps need a privacy policy URL in App Store Connect and usage descriptions in Info.plist for every read/write category.
  • Sign in with Apple is mandatory if you offer any other login. Adding the button takes 30 seconds; a rejection and resubmit takes 3–7 days.

The shortcut: generate the scaffold, write the wearable code

After building a few of these, here's the honest split. The parts that make the app yours — the device protocol, the training-load math, the coaching UX — are maybe 25% of the code. The other 75% is scaffold everyone builds identically: onboarding, permission flows, tab bar, session list, chart cards, settings, auth, a Postgres schema for sessions and samples.

That 75% is what an AI app builder like RapidNative generates from a prompt: describe the six screens and the Supabase data layer once, get an Expo project with expo-router routes, a wired-up store, and RLS policies, then drop the BLE and HealthKit code from this post into it. You still write the interesting code. You just don't rewrite the tab bar again.

Wrapping up

Building a fitness wearable app in React Native isn't hard because tools are missing — it's hard because there are 30 small integrations (permissions, background modes, BLE reconnect, health-store round-trips) and every one has an edge case. Nail the stack, design screens before touching a peripheral, and treat disconnection as a first-class state, and you'll ship in weeks rather than quarters.

What wearable are you building against? Drop a comment — especially if you've fought a vendor BLE protocol that doesn't follow the GATT spec. I'd like to hear which ones are the worst.

Top comments (0)