BLE & NFC Patterns
Quick Guide: Use react-native-ble-plx for BLE (scanning, connecting, GATT read/write/monitor). Use react-native-nfc-manager for NFC (NDEF read/write, tag technology access). BLE values are Base64-encoded -- decode before use. NFC operations follow request-technology/operate/cancel-technology lifecycle. Always clean up: remove BLE subscriptions, call cancelTechnologyRequest() for NFC, and destroy() the BleManager. MTU defaults to 23 bytes (20 usable) -- negotiate higher on Android. iOS auto-negotiates up to 187 bytes.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)
(You MUST call destroy() on BleManager when deallocating resources -- leaking the manager causes native memory leaks and zombie listeners)
(You MUST call discoverAllServicesAndCharacteristics() after connecting before any read/write/monitor operations -- GATT structure is not available until discovered)
(You MUST call cancelTechnologyRequest() in a finally block after every NFC operation -- failing to release the NFC session blocks subsequent scans)
(You MUST check BLE adapter state (PoweredOn) before scanning -- scanning while powered off or unauthorized throws errors silently on some devices)
(You MUST remove all BLE subscriptions (scan listeners, characteristic monitors, disconnect listeners) on cleanup -- leaked subscriptions cause crashes after component unmount)
</critical_requirements>
Auto-detection: react-native-ble-plx, BleManager, startDeviceScan, connectToDevice, monitorCharacteristicForDevice, writeCharacteristicWithResponseForDevice, readCharacteristicForDevice, requestMTUForDevice, react-native-nfc-manager, NfcManager, NfcTech, Ndef, requestTechnology, cancelTechnologyRequest, writeNdefMessage, ndefHandler, useCodeScanner BLE, BLE scanning, NFC tag, NDEF record, characteristic notification, GATT
When to use:
- Scanning for and connecting to BLE peripherals (IoT sensors, wearables, medical devices)
- Reading/writing BLE GATT characteristics and monitoring notifications
- Reading NDEF tags or writing NDEF records to NFC tags
- Implementing background BLE reconnection with state restoration
- MTU negotiation for large data transfers over BLE
- Accessing low-level NFC technologies (NfcA, IsoDep, MifareUltralight)
When NOT to use:
- Classic Bluetooth audio/file transfer (different protocol, different libraries)
- BLE peripheral/server mode (react-native-ble-plx is central-only)
- Web-based Bluetooth (use Web Bluetooth API)
- Wi-Fi Direct or peer-to-peer networking
Key patterns covered:
- BLE lifecycle: scan, connect, discover services, read/write/monitor, disconnect
- Battery-efficient scanning with UUID filters and scan modes
- MTU negotiation (Android explicit, iOS automatic)
- Characteristic subscriptions (notifications/indications) with cleanup
- BLE reconnection and disconnect monitoring
- NFC NDEF read/write lifecycle with technology request/cancel
- NFC technology types and platform availability (iOS vs Android)
- Permission handling for both BLE and NFC
Detailed Resources:
- examples/core.md - BLE scanning, connecting, GATT operations, disconnect handling
- examples/nfc.md - NFC NDEF reading/writing, technology types, platform differences
- reference.md - API quick reference, permission matrix, decision frameworks
<philosophy>
Philosophy
BLE and NFC are hardware communication protocols with fundamentally different interaction models:
BLE is connection-oriented and long-lived. You scan for devices, establish a persistent connection, discover the GATT service/characteristic tree, then read/write/subscribe to characteristics over time. Connections can last minutes to hours. The main challenges are connection lifecycle management, reconnection, and battery-efficient scanning.
NFC is session-oriented and brief. You request a technology, tap a tag, perform one operation (read or write), and release the technology. Sessions last seconds. The main challenges are platform differences (iOS vs Android technology support) and ensuring proper cleanup.
Core principles:
- Lifecycle-driven -- BLE connections have a strict flow: scan -> connect -> discover -> operate -> disconnect. Skipping steps causes silent failures.
- Subscription-based -- BLE characteristic monitoring returns Subscription objects that MUST be removed on cleanup. NFC technology requests MUST be canceled in finally blocks.
- Base64-encoded -- All BLE characteristic values are Base64-encoded strings. Decode before use, encode before write.
- Platform-aware -- BLE permissions differ significantly between Android versions. NFC technology support varies between iOS and Android. Always check platform capabilities.
When to use BLE:
- Continuous communication with a peripheral (sensor readings, device control)
- Background monitoring (health devices, beacons)
- Large data transfers requiring MTU negotiation
When to use NFC:
- One-tap interactions (read a tag, write a tag, verify identity)
- Quick data exchange without pairing
- Tag provisioning or configuration
When NOT to use either:
- High-bandwidth streaming (use Wi-Fi or classic Bluetooth)
- Cross-platform web apps (use Web Bluetooth / Web NFC)
</philosophy>
<patterns>
Core Patterns
Pattern 1: BLE Manager Initialization and State
Create one BleManager instance for the app lifetime. Check adapter state before operations.
import { BleManager, State } from "react-native-ble-plx";
const manager = new BleManager();
// Wait for Bluetooth to be ready
manager.onStateChange((state) => {
if (state === State.PoweredOn) {
// Safe to scan
}
}, true); // true = emit current state immediately
// Cleanup on app teardown
manager.destroy();
Why good: single manager instance, state checked before operations, destroy called on cleanup, true flag emits current state immediately so you don't miss the initial PoweredOn
See examples/core.md for full initialization with background mode support and state restoration.
Pattern 2: BLE Scanning with Filters
Filter scans by service UUIDs for battery efficiency. Stop scanning as soon as you find the target device.
const HEART_RATE_SERVICE_UUID = "0000180d-0000-1000-8000-00805f9b34fb";
const SCAN_TIMEOUT_MS = 10000;
manager.startDeviceScan(
[HEART_RATE_SERVICE_UUID], // Filter by service UUID -- null scans ALL devices
{ allowDuplicates: false },
(error, device) => {
if (error) {
/* handle */ return;
}
if (device?.name === "MyDevice") {
manager.stopDeviceScan();
// Connect to device
}
},
);
// Always set a timeout to stop scanning
setTimeout(() => manager.stopDeviceScan(), SCAN_TIMEOUT_MS);
Why good: UUID filter reduces battery drain, scan timeout prevents indefinite scanning, duplicates disabled to reduce callback noise, scan stopped once target found
See examples/core.md for complete scanning with Android scan modes and permission handling.
Pattern 3: BLE Connection and GATT Discovery
Connect, discover services/characteristics, then operate. Always monitor for disconnects.
const device = await manager.connectToDevice(deviceId, {
requestMTU: 512, // Android only -- request larger MTU during connection
});
// MUST discover before read/write/monitor
await device.discoverAllServicesAndCharacteristics();
// Monitor for unexpected disconnects
const disconnectSubscription = device.onDisconnected(
(error, disconnectedDevice) => {
// Handle reconnection logic
},
);
// Cleanup
disconnectSubscription.remove();
await device.cancelConnection();
Why good: MTU requested during connection (Android), GATT discovered before operations, disconnect monitored for reconnection, subscription removed on cleanup
See examples/core.md for full connection flow with retry logic and error handling.
Pattern 4: Reading and Writing BLE Characteristics
All values are Base64-encoded. Decode after read, encode before write.
import { decode as atob, encode as btoa } from "base-64";
const SERVICE_UUID = "0000180d-0000-1000-8000-00805f9b34fb";
const CHAR_UUID = "00002a37-0000-1000-8000-00805f9b34fb";
// Read
const characteristic = await device.readCharacteristicForService(
SERVICE_UUID,
CHAR_UUID,
);
const decodedValue = atob(characteristic.value ?? "");
// Write with response (acknowledged)
const base64Value = btoa("command-data");
await device.writeCharacteristicWithResponseForService(
SERVICE_UUID,
CHAR_UUID,
base64Value,
);
// Write without response (faster, no acknowledgment)
await device.writeCharacteristicWithoutResponseForService(
SERVICE_UUID,
CHAR_UUID,
base64Value,
);
Why good: named constants for UUIDs, Base64 decode/encode explicit, write-with-response used for reliable delivery, write-without-response available for speed
See examples/core.md for complete read/write patterns with error handling.
Pattern 5: Characteristic Monitoring (Notifications)
Subscribe to characteristic value changes. Returns a Subscription that MUST be removed.
const subscription = device.monitorCharacteristicForService(
SERVICE_UUID,
CHAR_UUID,
(error, characteristic) => {
if (error) {
/* handle */ return;
}
const value = atob(characteristic?.value ?? "");
// Process incoming notification
},
);
// CRITICAL: Remove on cleanup
subscription.remove();
Why good: subscription stored for cleanup, error handled in callback, value decoded from Base64
Gotcha: Check characteristic.isNotifiable or characteristic.isIndicatable before monitoring -- not all characteristics support notifications.
See examples/core.md for monitoring with React hooks and cleanup patterns.
Pattern 6: MTU Negotiation
Larger MTU = fewer packets for big payloads. iOS negotiates automatically (up to 187 bytes). Android requires explicit request.
import { Platform } from "react-native";
const DESIRED_MTU = 512;
const BLE_HEADER_BYTES = 3;
// Request MTU after connection (Android only -- iOS auto-negotiates)
if (Platform.OS === "android") {
const updatedDevice = await device.requestMTU(DESIRED_MTU);
const usableBytes = updatedDevice.mtu - BLE_HEADER_BYTES;
// usableBytes is the max payload per packet
}
Why good: platform check avoids unnecessary call on iOS, named constants for MTU and header size, usable bytes calculated correctly (MTU minus 3-byte ATT header)
See reference.md for MTU size recommendations by use case.
Pattern 7: NFC NDEF Read
Request NDEF technology, read the tag, clean up in finally block.
import NfcManager, { NfcTech } from "react-native-nfc-manager";
// Initialize once at app start
await NfcManager.start();
async function readNdefTag(): Promise<string | null> {
try {
await NfcManager.requestTechnology(NfcTech.Ndef);
const tag = await NfcManager.getTag();
if (tag?.ndefMessage && tag.ndefMessage.length > 0) {
// tag.ndefMessage is an array of NDEF records
// Each record has: tnf, type, id, payload (all number arrays)
return String.fromCharCode(...tag.ndefMessage[0].payload);
}
return null;
} catch (error) {
// User cancelled or tag not found
return null;
} finally {
// CRITICAL: Always release the NFC session
await NfcManager.cancelTechnologyRequest();
}
}
Why good: technology request and cancel in try/finally, error handled for user cancellation, start() called once at app initialization, tag null-checked before access
See examples/nfc.md for complete NDEF reading with record parsing.
Pattern 8: NFC NDEF Write
Request technology, encode the message, write, release.
import NfcManager, { NfcTech, Ndef } from "react-native-nfc-manager";
async function writeNdefTag(url: string): Promise<boolean> {
try {
await NfcManager.requestTechnology(NfcTech.Ndef);
const bytes = Ndef.encodeMessage([Ndef.uriRecord(url)]);
await NfcManager.ndefHandler.writeNdefMessage(bytes);
return true;
} catch (error) {
return false;
} finally {
await NfcManager.cancelTechnologyRequest();
}
}
Why good: Ndef utility encodes records properly, try/finally ensures cleanup, boolean return signals success/failure
See examples/nfc.md for text records, multi-record messages, and platform-specific handling.
Pattern 9: BLE Permissions
BLE permissions differ significantly across Android versions. iOS requires Info.plist entries.
import { Platform, PermissionsAndroid } from "react-native";
async function requestBlePermissions(): Promise<boolean> {
if (Platform.OS === "android") {
const apiLevel = Platform.Version;
if (apiLevel >= 31) {
// Android 12+: BLUETOOTH_SCAN + BLUETOOTH_CONNECT
const results = await PermissionsAndroid.requestMultiple([
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
]);
return Object.values(results).every((r) => r === "granted");
}
// Android <12: ACCESS_FINE_LOCATION
const result = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
);
return result === "granted";
}
// iOS: permissions handled via Info.plist (NSBluetoothAlwaysUsageDescription)
return true;
}
Why good: Android API level checked for correct permission set, Android 12+ uses new Bluetooth permissions, pre-12 falls back to location permission, iOS handled via plist
See reference.md for the full permission matrix across platforms and Android versions.
</patterns>
<decision_framework>
Decision Framework
BLE vs NFC
What kind of hardware interaction?
|
+-> Continuous connection with a peripheral (sensor, wearable)?
| +-> BLE -- long-lived connection with GATT operations
|
+-> One-tap read/write (tag, card)?
| +-> NFC -- session-based, tap and go
|
+-> Background monitoring of nearby devices?
| +-> BLE -- background scanning with state restoration
|
+-> Quick device provisioning (write config to tag)?
+-> NFC -- write NDEF record, tap target device
BLE Write Method
Which write method?
|
+-> Data MUST arrive reliably?
| +-> writeCharacteristicWithResponseForService (acknowledged, slower)
|
+-> Speed matters more than reliability?
| +-> writeCharacteristicWithoutResponseForService (fire-and-forget, faster)
|
+-> Large payload (> MTU)?
+-> Negotiate higher MTU first, then use write-with-response
NFC Technology Selection
What kind of NFC tag?
|
+-> Standard NDEF content (URL, text, MIME)?
| +-> NfcTech.Ndef (iOS + Android)
|
+-> ISO 14443-3A tag (raw commands)?
| +-> NfcTech.NfcA (iOS + Android)
|
+-> Smart card / ISO 7816 (APDU commands)?
| +-> NfcTech.IsoDep (iOS + Android)
|
+-> Mifare Classic (Android only)?
| +-> NfcTech.MifareClassic
|
+-> Mifare Ultralight (Android only)?
+-> NfcTech.MifareUltralight
</decision_framework>
<red_flags>
RED FLAGS
High Priority Issues:
- Skipping
discoverAllServicesAndCharacteristics() after connecting -- read/write/monitor calls fail silently or throw errors because GATT structure is not cached
- Not removing BLE subscriptions on cleanup -- leaked subscriptions fire callbacks after component unmount, causing "setState on unmounted component" crashes
- Forgetting
cancelTechnologyRequest() in NFC finally block -- NFC session stays locked, all subsequent NFC operations fail until app restart
- Creating multiple BleManager instances -- each instance allocates native resources. Create ONE and share it. Call
destroy() only once on app teardown.
- Scanning without UUID filter and without timeout -- scans all devices indefinitely, drains battery rapidly
Medium Priority Issues:
- Not checking
State.PoweredOn before scanning -- scanning while Bluetooth is off throws errors on some devices and does nothing on others
- Not handling Base64 encoding/decoding for BLE characteristic values -- raw Base64 strings are not human-readable and cannot be compared directly
- Using
writeCharacteristicWithoutResponseForService for critical data -- fire-and-forget may lose data; use write-with-response for reliability
- Not requesting MTU on Android -- default 23-byte MTU means 20 usable bytes per packet, making large transfers extremely slow
- Hardcoding service/characteristic UUIDs inline instead of using named constants
Gotchas & Edge Cases:
- iOS BLE device IDs are random UUIDs, not MAC addresses -- they can change after Bluetooth is toggled or the device restarts. Do not persist iOS device IDs for reconnection; use service UUID scanning instead.
- Android 12+ changed BLE permissions --
BLUETOOTH_SCAN and BLUETOOTH_CONNECT replaced ACCESS_FINE_LOCATION for BLE scanning (set neverForLocation: true in Expo config if scanning does not need location)
- Android 14+ defaults MTU to 517 bytes --
requestMTU may be unnecessary on newer Android devices. Check device.mtu after connection.
- iOS auto-negotiates MTU up to 187 bytes -- calling
requestMTU on iOS has no effect
onDeviceDisconnected fires once per registration -- you must re-register the listener after each reconnection if you want continued disconnect monitoring
- NfcTech.NfcB, NfcF, NfcV are Android-only -- iOS has different equivalents (Iso15693IOS, FelicaIOS). Always check platform before requesting a technology.
- NFC on iOS shows a system scan dialog -- you cannot customize it beyond the
alertMessage. On Android, scanning is silent.
- BLE
allowDuplicates is iOS-only -- Android always emits duplicates. Deduplicate in your scan callback using a Set of device IDs.
- HCE (Host Card Emulation) is NOT supported by react-native-nfc-manager -- use a dedicated library like
react-native-hce for card emulation
- NFC
getTag() returns the last discovered tag -- if no tag was tapped during the session, it returns the previous tag. Always request technology first.
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md
(You MUST call destroy() on BleManager when deallocating resources -- leaking the manager causes native memory leaks and zombie listeners)
(You MUST call discoverAllServicesAndCharacteristics() after connecting before any read/write/monitor operations -- GATT structure is not available until discovered)
(You MUST call cancelTechnologyRequest() in a finally block after every NFC operation -- failing to release the NFC session blocks subsequent scans)
(You MUST check BLE adapter state (PoweredOn) before scanning -- scanning while powered off or unauthorized throws errors silently on some devices)
(You MUST remove all BLE subscriptions (scan listeners, characteristic monitors, disconnect listeners) on cleanup -- leaked subscriptions cause crashes after component unmount)
Failure to follow these rules will cause native crashes, memory leaks, blocked NFC sessions, and battery drain.
</critical_reminders>