React Native BLE Tutorial with Expo (2026): react-native-ble-plx and Config Plugin

A practical, hands-on guide to Bluetooth Low Energy in React Native with Expo and react-native-ble-plx v3: dev builds, iOS/Android permissions, scanning, GATT services, characteristic reads/writes, notifications, and background reconnection.

React Native BLE Expo Tutorial (2026)

Updated: August 9, 2026

Building a Bluetooth Low Energy (BLE) app in React Native in 2026 means pairing react-native-ble-plx with an Expo development build and an Expo config plugin. The library isn't compatible with Expo Go, but once you set up a custom dev client and the plugin, you can scan, connect, read, write, and subscribe to GATT characteristics from JavaScript in about 80 lines of code. This guide walks through the full flow: installing the library, configuring iOS and Android permissions, scanning for peripherals, discovering services and characteristics, and handling the reconnection and background-mode edge cases most tutorials skip.

  • react-native-ble-plx v3.x is the de facto BLE client for React Native in 2026 and supports the New Architecture, but it requires a development build (Expo Go can't load its native module).
  • Install the Expo config plugin (@config-plugins/react-native-ble-plx) so eas build automatically injects iOS NSBluetoothAlwaysUsageDescription and the Android 12+ BLUETOOTH_SCAN/BLUETOOTH_CONNECT permissions.
  • On Android 11 and below you still need ACCESS_FINE_LOCATION at runtime to scan, even for BLE. On Android 12+ you can request the new Bluetooth runtime permissions instead and drop location.
  • The Central role (your phone) discovers a Peripheral, connects, reads Services (UUIDs), and enumerates Characteristics. Every read, write, and subscribe happens on a Characteristic UUID.
  • Background scanning on iOS requires the bluetooth-central UIBackgroundMode. On Android use a foreground service or the SCAN_MODE_LOW_POWER filter to avoid throttling.
  • Always monitor onDisconnected and wrap connect calls in retry logic. BLE links drop on RF interference, low battery, or when the OS suspends your app.

What is Bluetooth Low Energy?

Bluetooth Low Energy (BLE, marketed as Bluetooth Smart) is a wireless protocol designed for short bursts of small data over Bluetooth 4.0+ radios. Unlike Bluetooth Classic, which streams audio and keeps a long-lived connection open, BLE peripherals sleep between advertising packets and draw microamps of current. That's why fitness bands, glucose monitors, thermostats, beacons, and most Internet-of-Things sensors ship BLE only.

The BLE architecture centers on two roles. The Central (your React Native app on a phone) initiates the connection. The Peripheral (the sensor) advertises its presence with a short packet containing a device name and one or more Service UUIDs. Once the Central connects, it queries the peripheral's GATT (Generic Attribute Profile) table, which is a nested structure: a peripheral exposes Services, each Service contains Characteristics, and each Characteristic is a value you can read, write, or subscribe to for notifications. Every Service and Characteristic is addressed by a 16-bit or 128-bit UUID, and the UUIDs for common profiles (heart rate, battery level, environmental sensing) are standardized by the Bluetooth SIG assigned numbers registry.

In practice, working with BLE from React Native looks like a chain of asynchronous calls: startDeviceScanconnectToDevicediscoverAllServicesAndCharacteristicsreadCharacteristicForService or monitorCharacteristicForService. The rest of this article implements exactly that chain against a real device.

Installing react-native-ble-plx in an Expo project

In 2026, the ecosystem has consolidated around react-native-ble-plx, currently at version 3.x. It supports the New Architecture (Fabric plus Turbo Modules), ships prebuilt for both iOS and Android, and (critically) has a maintained Expo config plugin. The library includes native code, so it can't run in Expo Go. You have to produce an Expo development build with EAS or expo run:ios/expo run:android.

Add the library and its config plugin to a fresh Expo SDK 55+ project:

npx create-expo-app@latest my-ble-app --template blank-typescript
cd my-ble-app

npx expo install react-native-ble-plx
npx expo install @config-plugins/react-native-ble-plx

Then register the config plugin in app.json so Expo Prebuild injects the correct native project settings on every build:

{
  "expo": {
    "name": "my-ble-app",
    "slug": "my-ble-app",
    "plugins": [
      [
        "@config-plugins/react-native-ble-plx",
        {
          "isBackgroundEnabled": true,
          "modes": ["central"],
          "bluetoothAlwaysPermission": "Allow $(PRODUCT_NAME) to connect to Bluetooth sensors"
        }
      ]
    ]
  }
}

If you're new to Expo config plugins, the parameters above (isBackgroundEnabled, modes, permission strings) get transformed into Info.plist keys and AndroidManifest.xml entries during prebuild. Our deeper Expo config plugins guide explains how these mods work under the hood.

Finally, generate a development build so the native module is included in the binary:

eas build --profile development --platform ios
eas build --profile development --platform android

Install the resulting .ipa/.apk on a physical device (BLE does not work in the iOS Simulator) and run npx expo start --dev-client. If you need a refresher, the Expo development builds documentation covers profile configuration and installation.

Configuring Bluetooth permissions on iOS and Android

Permissions are where most BLE tutorials fall apart, because the rules changed twice in the last four Android releases and Apple tightened iOS descriptions in iOS 17. The config plugin handles the manifest declarations, but you still have to request runtime permission before the first scan.

iOS: Info.plist and TCC

On iOS, the config plugin injects NSBluetoothAlwaysUsageDescription into Info.plist. Without a human-readable description, the App Store rejects the binary and the OS silently blocks scans. If isBackgroundEnabled is true, the plugin also adds UIBackgroundModes = bluetooth-central, which lets iOS wake your app when a matching advertisement is heard.

Android 12+ (API 31 and above)

Google split the legacy BLUETOOTH permission into three runtime permissions: BLUETOOTH_SCAN, BLUETOOTH_CONNECT, and BLUETOOTH_ADVERTISE. On Android 12+ you no longer need location for scanning if you set usesPermissionFlags="neverForLocation", which the plugin does by default. The Android Bluetooth permissions guide spells out the exact manifest attributes if you want to double-check.

Android 11 and below

Scanning still requires ACCESS_FINE_LOCATION at runtime, because Google historically considered BLE beacons a proxy for location.

Wire the runtime request with the modern replacements for expo-permissions. Honestly, the cleanest pattern in 2026 is PermissionsAndroid plus expo-location for the pre-12 case:

import { PermissionsAndroid, Platform } from 'react-native';
import * as Location from 'expo-location';

export async function requestBlePermissions(): Promise<boolean> {
  if (Platform.OS === 'ios') return true;

  const apiLevel = Platform.Version as number;
  if (apiLevel >= 31) {
    const granted = await PermissionsAndroid.requestMultiple([
      PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
      PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
    ]);
    return Object.values(granted).every(
      (v) => v === PermissionsAndroid.RESULTS.GRANTED
    );
  }

  const { status } = await Location.requestForegroundPermissionsAsync();
  return status === 'granted';
}

How do you scan for BLE devices in React Native?

Scanning in react-native-ble-plx is a callback-driven API. You create a single BleManager instance for the app's lifetime, wait until Bluetooth state is PoweredOn, and then call startDeviceScan. Each advertisement fires the callback with a Device object.

import { useEffect, useRef, useState } from 'react';
import { BleManager, Device } from 'react-native-ble-plx';
import { requestBlePermissions } from './permissions';

export function useBleScan() {
  const managerRef = useRef<BleManager>();
  const [devices, setDevices] = useState<Record<string, Device>>({});
  const [scanning, setScanning] = useState(false);

  useEffect(() => {
    managerRef.current = new BleManager();
    return () => managerRef.current?.destroy();
  }, []);

  async function start() {
    if (!(await requestBlePermissions())) return;
    setScanning(true);
    setDevices({});
    managerRef.current!.startDeviceScan(null, null, (error, device) => {
      if (error) {
        console.warn('Scan error', error);
        setScanning(false);
        return;
      }
      if (device?.name) {
        setDevices((prev) => ({ ...prev, [device.id]: device }));
      }
    });
  }

  function stop() {
    managerRef.current?.stopDeviceScan();
    setScanning(false);
  }

  return { devices: Object.values(devices), scanning, start, stop };
}

Three details worth calling out. First, the first argument to startDeviceScan is a string[] | null of Service UUIDs to filter for. Passing null returns every advertising peripheral in range, which is convenient for exploration but drains battery. In production, always filter for the Service UUIDs you actually care about.

Second, the same device can advertise multiple times per second, so we deduplicate by device.id (the MAC address on Android, or a per-app UUID on iOS, since Apple doesn't expose real MAC addresses). Third, remember to stopDeviceScan as soon as the user picks a device or your UI unmounts. Leaving a scan running is the number-one battery bug in BLE apps. I hit exactly this shipping my first BLE prototype: the app ran fine in the office and drained a whole battery in the field during a demo because the scan callback was still firing behind a modal.

Connecting to a device and discovering services

Once the user picks a device from the scan list, the workflow is: stop scanning, connect, discover services, then hand the connected Device to feature code. Every step returns a Promise, so async/await keeps the code linear.

async function connectAndDiscover(manager: BleManager, deviceId: string) {
  manager.stopDeviceScan();
  const device = await manager.connectToDevice(deviceId, {
    autoConnect: false,
    timeout: 10_000,
  });
  await device.discoverAllServicesAndCharacteristics();

  const services = await device.services();
  for (const service of services) {
    const characteristics = await service.characteristics();
    console.log(
      `Service ${service.uuid} exposes ${characteristics.length} characteristics`
    );
  }
  return device;
}

The discoverAllServicesAndCharacteristics call is mandatory. Under the hood it triggers a GATT discovery on the peripheral and caches the result on the Device instance. If you skip it, subsequent read/write/monitor calls fail with Characteristic not found.

A few production-grade improvements to layer on top. Pass autoConnect: true to let the OS reconnect automatically after a brief disconnect (useful for wearables that go in and out of range). Set a request MTU (await device.requestMTU(185)) to increase per-packet payload from the default 23 bytes to 185, which roughly triples read throughput on modern chipsets. And register onDisconnected before you start any long-running subscriptions, so your app cleans up state when the link drops.

Reading, writing, and subscribing to characteristics

Every meaningful BLE operation happens on a Characteristic UUID inside a Service UUID. The Bluetooth SIG publishes standard UUIDs for common profiles. For example, the Battery Service is 0000180F-0000-1000-8000-00805F9B34FB and its Battery Level characteristic is 00002A19-0000-1000-8000-00805F9B34FB. Custom peripherals define their own 128-bit UUIDs; ask the hardware vendor for the datasheet.

Reading a value once:

import { Buffer } from 'buffer';

const BATTERY_SERVICE = '0000180f-0000-1000-8000-00805f9b34fb';
const BATTERY_LEVEL   = '00002a19-0000-1000-8000-00805f9b34fb';

async function readBatteryLevel(device: Device) {
  const characteristic = await device.readCharacteristicForService(
    BATTERY_SERVICE,
    BATTERY_LEVEL
  );
  const bytes = Buffer.from(characteristic.value ?? '', 'base64');
  return bytes.readUInt8(0);
}

Characteristic values are transported as base64-encoded byte arrays, and the peripheral spec tells you how to decode them. Battery Level is a single unsigned byte from 0 to 100; a heart-rate monitor sends a multi-byte flag structure.

Writing a value uses the opposite path. Encode a Buffer as base64 and call writeCharacteristicWithResponseForService when you need an ACK, or writeCharacteristicWithoutResponseForService for fire-and-forget commands like turning on an LED.

Subscribing to notifications is the most common pattern. You tell the peripheral, "wake me up whenever this value changes," and the OS fires your callback each time:

const subscription = device.monitorCharacteristicForService(
  HEART_RATE_SERVICE,
  HEART_RATE_MEASUREMENT,
  (error, characteristic) => {
    if (error) {
      console.warn('Notification error', error);
      return;
    }
    const bytes = Buffer.from(characteristic?.value ?? '', 'base64');
    const bpm = bytes.readUInt8(1);
    setHeartRate(bpm);
  }
);

// Later, when the component unmounts:
subscription.remove();

Always store the returned Subscription and call remove() in your cleanup path. Otherwise the callback keeps holding a closure over stale React state and you leak memory across component remounts.

Reconnection and background scanning

Real-world BLE links drop constantly. The user walks out of range, the peripheral's battery dips, RF interference from Wi-Fi 6E stomps on 2.4 GHz, or iOS suspends the process. A resilient app treats disconnections as normal and reconnects transparently.

Register the disconnection listener immediately after connecting:

function watchConnection(manager: BleManager, device: Device) {
  const subscription = device.onDisconnected((error, disconnected) => {
    console.log('Disconnected', disconnected.id, error?.message);
    reconnectWithBackoff(manager, disconnected.id).catch(console.warn);
  });
  return subscription;
}

async function reconnectWithBackoff(manager: BleManager, id: string) {
  for (let attempt = 0; attempt < 5; attempt++) {
    try {
      const device = await manager.connectToDevice(id, { timeout: 5_000 });
      await device.discoverAllServicesAndCharacteristics();
      return device;
    } catch (e) {
      await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
    }
  }
  throw new Error('reconnect failed after 5 attempts');
}

Background behavior differs sharply between platforms. On iOS, if you declared UIBackgroundModes = bluetooth-central, the OS will wake your app when a matching advertisement is heard, but only if you called startDeviceScan with an explicit Service UUID filter. Unfiltered scans don't survive backgrounding. On Android, foreground scans are throttled after 30 seconds, and for a persistent connection you need a foreground service, which our React Native background tasks with Expo guide walks through in detail.

Common BLE pitfalls and how to debug them

After shipping several BLE products, the same handful of mistakes come up over and over. Here's the checklist I run through when a device "just won't connect":

  • Scanning in the iOS Simulator. The Simulator has no Bluetooth radio. Always test on a physical iPhone.
  • Missing runtime permission on Android 12+. The manifest declaration isn't enough. You must call PermissionsAndroid.requestMultiple before every scan attempt, because users can revoke permission from Settings.
  • Location services off on Android 11 and below. Even with the runtime permission, if the user has toggled Location off in system settings, startDeviceScan silently returns zero results.
  • Forgetting to call discoverAllServicesAndCharacteristics. Reads and writes will fail with cryptic UUID errors until you do.
  • Reusing a stale Device object after reconnect. Each reconnection returns a fresh instance; hold the ID, not the object.
  • Case-sensitive UUIDs. react-native-ble-plx normalizes to lowercase; the Bluetooth SIG datasheet often prints uppercase. Compare with .toLowerCase().
  • Multiple BleManager instances. Create exactly one for the app; each instance opens its own GATT client and eats file descriptors.
  • App killed by OOM during background scan. Add a foreground notification (Android) or accept that iOS will pause you after about 10 seconds without a filtered scan.

For live debugging, the nRF Connect app from Nordic Semiconductor is invaluable. It acts as a generic BLE Central, so you can verify the peripheral is advertising and that its GATT table matches your expectations before you blame your React Native code. On iOS, enable Bluetooth Logging in Settings → Developer to capture HCI traces.

Frequently Asked Questions

Can you use Bluetooth Low Energy in Expo Go?

No. react-native-ble-plx ships native code that isn't bundled into Expo Go's runtime, so calling new BleManager() throws immediately. You'll need to create an Expo development build with EAS Build (or expo run:ios/expo run:android) that includes the BLE native module. Expo Go remains useful for the rest of your UI while you develop.

What's the difference between BLE and Bluetooth Classic in React Native?

Bluetooth Classic uses a persistent, high-throughput link designed for audio and file transfer, and it requires pairing at the OS level. BLE uses short advertising packets and small, low-power GATT reads, and it's what you use to talk to sensors, wearables, and IoT devices. react-native-ble-plx only speaks BLE; for Bluetooth Classic (e.g., an SPP serial printer) you would use react-native-bluetooth-classic instead.

Do I need Location permission for Bluetooth on Android?

Only on Android 11 (API 30) and below, where scanning still requires ACCESS_FINE_LOCATION. On Android 12+ (API 31 and up), you can request the new BLUETOOTH_SCAN permission with the neverForLocation flag (which the Expo config plugin sets by default) and skip location entirely. Target Android 12+ and check Platform.Version to branch cleanly.

How do I scan for BLE devices in the background on iOS?

Set isBackgroundEnabled: true in the config plugin (which adds UIBackgroundModes = bluetooth-central to Info.plist) and pass an explicit array of Service UUIDs to startDeviceScan. iOS will wake your app when a matching advertisement is heard, even if suspended. Unfiltered background scans are throttled aggressively and stop within seconds of backgrounding.

Does react-native-ble-plx work with the New Architecture?

Yes. Version 3.x ships Turbo Module and Fabric-compatible bindings and works with React Native 0.75+ under the New Architecture. You don't need to enable any special flag; a fresh expo prebuild with the config plugin produces a working project on both the New and legacy architectures.

About the Author Editorial Team

Our team of expert writers and editors.