React Native Watch Connectivity Pro
A React Native library for seamless communication between iOS apps and Apple Watch applications.
For a terser, machine-readable description of the real API surface and its quirks (written for LLM coding agents), see
llms.txt. Where the two disagree, trustllms.txt— it is the more tightly-maintained source of truth.
Features
- Two-way messaging between iPhone and Apple Watch
- Application context synchronization
- Reliable file transfers with progress tracking
- Complication data updates
- Session state monitoring (reachability, etc.)
- Automatic message queuing when watch is unavailable
- Support for handling responses from the watch
- Robust error handling & recovery
- Enhanced reachability detection when app returns from background
- Active ping mechanism to wake up watch connectivity
- Comprehensive TypeScript definitions
- Queue management API for monitoring and controlling message delivery
Current Status
This package works for basic messaging between React Native iOS apps and Apple Watch. The following features are currently working:
- Session initialization and activation
- Sending messages from phone to watch
- Receiving messages from watch to phone
- Application context synchronization
- Session state monitoring
- Message queueing when watch is unavailable
- Automatic queue processing when watch becomes available
- Queue management (status checking, manual processing, clearing)
- Enhanced reachability detection for background/foreground transitions
See TODO.md for a complete overview of implemented and planned features.
Installation
npm install react-native-watch-connectivity-pro
# or
yarn add react-native-watch-connectivity-pro
iOS
cd ios && pod install
Setup Requirements
Create a watchOS App Extension Your iOS app must include a watchOS app extension. See Apple's documentation on Creating a watchOS App.
App Groups Configure app groups for your main app and watch extension to share data. See Configuring App Groups.
Initialize Early in App Lifecycle For best results, initialize the connectivity service early in your app's lifecycle, ideally in your main App.js/App.tsx file.
Use NativeEventEmitter Always use React Native's NativeEventEmitter to set up event listeners.
Usage
Initializing the Watch Connectivity Service
import React, { useEffect } from 'react';
import { NativeEventEmitter, NativeModules } from 'react-native';
import WatchConnectivity from 'react-native-watch-connectivity-pro';
function App() {
useEffect(() => {
// Initialize watch connectivity early in the app lifecycle
const initWatchConnectivity = async () => {
try {
const status = await WatchConnectivity.initSession();
console.log('Watch connectivity initialized:', status);
} catch (error) {
console.error('Failed to initialize watch connectivity:', error);
}
};
initWatchConnectivity();
// Set up event listeners using NativeEventEmitter
const eventEmitter = new NativeEventEmitter(NativeModules.RNWatchConnectivityPro);
const messageSubscription = eventEmitter.addListener(
'messageReceived',
(event) => {
console.log('Received message from watch:', event.message);
}
);
// Listen for queue updates
const queueUpdateSubscription = eventEmitter.addListener(
'messageQueueUpdated',
(queueStatus) => {
console.log('Message queue updated:', queueStatus);
}
);
return () => {
// Clean up subscriptions
messageSubscription.remove();
queueUpdateSubscription.remove();
};
}, []);
// Rest of your app
return (
// ...
);
}
Sending Messages to Watch
sendMessage resolves void on success. If the session isn't activated or
the watch isn't reachable, it rejects with error code 'QUEUED' — and as
of the reliability fixes, the message really has been added to the in-memory
retry queue in that case (previously the plain sendMessage path silently
dropped it; only the reply-handler path enqueued). Treat QUEUED as
"accepted for later delivery", not a hard failure:
// Send a simple message to the watch
const sendMessageToWatch = async () => {
try {
await WatchConnectivity.sendMessage(
{
type: 'test',
message: 'Hello from React Native!',
timestamp: Date.now() / 1000
},
(response) => {
console.log('Watch replied:', response);
}
);
console.log('Message sent successfully');
} catch (error: any) {
if (error?.code === 'QUEUED') {
console.log('Watch unavailable — message was queued for later delivery');
} else {
console.error('Failed to send message:', error);
}
}
};
Outbound payloads (this call, updateApplicationContext, transferUserInfo,
transferCurrentComplicationUserInfo, and replies via replyToMessage) are
automatically passed through the package's plistSafe sanitizer before they
reach native — see Payload sanitization below. You
do not need to strip undefined/null/NaN yourself.
Receiving Messages from Watch
messageReceived fires for more than just interactive watch→phone messages —
it's also re-emitted for every received user-info transfer and every
received application context, so you can listen to a single event for
"something arrived from the watch." Every event body carries a source
field telling you which channel it came from: 'message', 'userInfo', or
'applicationContext'. In Debug builds with file logging enabled (the
default in Debug), a one-off diagnostic message with source: 'test' is
also emitted the first time checkWatchConnectivityStatus() runs — filter
it out, or rely on it never appearing in a Release build (file logging
defaults to OFF there).
// Using the NativeEventEmitter
const eventEmitter = new NativeEventEmitter(NativeModules.RNWatchConnectivityPro);
// Listen for messages from the watch
const messageSubscription = eventEmitter.addListener(
'messageReceived',
(event) => {
if (event.source === 'test') return; // debug-only diagnostic, ignore
// Check if the message has a reply handler
if (event.hasReplyHandler && event.handlerId) {
// Get the message content
const message = event.message;
console.log(`Received message from watch (source=${event.source}):`, message);
// Reply to the watch if needed
WatchConnectivity.replyToMessage(
event.handlerId,
{
acknowledged: true,
message: 'Message received!',
timestamp: Date.now() / 1000
}
).then(() => {
console.log('Reply sent successfully');
}).catch(error => {
console.error('Failed to send reply:', error);
});
} else {
// Process message without reply handler
console.log('Received message from watch (no reply):', event.message);
}
}
);
// Don't forget to remove the listener when not needed
// messageSubscription.remove();
Note on reply handlers: if you receive a message with hasReplyHandler /
handlerId and never call replyToMessage, the handler now expires after
30 seconds and the watch automatically gets a default reply
({"error": "reply timeout", "acknowledged": false}) instead of the
callback leaking forever.
Managing the Message Queue
When the watch is not reachable, messages sent via sendMessage are
automatically added to an in-memory retry queue owned by the native
module instance. The queue is automatically drained when the watch becomes
reachable again (on session activation, on a sessionWatchStateDidChange or
sessionReachabilityDidChange callback reporting reachable, or via a manual
processMessageQueue() call), in batches of 5 messages, 1 second apart. You
can also inspect and manage it directly:
// Get the current queue status
const checkQueueStatus = async () => {
try {
const status = await WatchConnectivity.getQueueStatus();
console.log('Queue status:', status);
// status: { count: number, isProcessing: boolean, oldestMessage: number, newestMessage: number }
// oldestMessage/newestMessage are epoch-SECOND timestamps of when those
// messages were enqueued (0 when the queue is empty) — plain numbers,
// not objects.
if (status.count > 0) {
console.log(`${status.count} messages waiting for watch to become available`);
console.log(`Oldest message queued at: ${new Date(status.oldestMessage * 1000)}`);
}
} catch (error) {
console.error('Failed to get queue status:', error);
}
};
// Manually force-drain the queue (normally happens automatically when the watch becomes reachable)
const processQueue = async () => {
try {
const result = await WatchConnectivity.processMessageQueue();
console.log(`Processed ${result.processed} queued messages`);
// result is just { processed: number } — if the session isn't ready or
// the queue is empty, `processed` is simply 0; there's no `reason` field.
} catch (error) {
console.error('Failed to process queue:', error);
}
};
// Clear the message queue
const clearQueue = async () => {
try {
const cleared = await WatchConnectivity.clearMessageQueue();
// Resolves a boolean (true), not a count — the queue was reset.
console.log('Message queue cleared:', cleared);
} catch (error) {
console.error('Failed to clear queue:', error);
}
};
Reliability notes:
- The queue is in-memory only — it does NOT survive app termination or a
crash. "Offline queueing" means "queued until the watch is reachable or
the app process ends, whichever comes first." For data that absolutely
must arrive (auth tokens, critical one-off payloads), prefer
transferUserInfo, which is OS-guaranteed and survives your app dying. - A drain that fails partway through (e.g. the watch drops reachability mid-batch) now requeues the failed messages instead of losing them, and stops draining rather than hot-looping against an unreachable watch.
- The bare native
sendMessagepath and thesendMessage-with-reply-handler path both enqueue on failure — there's no longer a silent-drop difference between them.
Detecting Watch Reachability and Queue Processing
You can monitor watch reachability and queue status to update your UI
accordingly. Note that initSession() resolves the full WatchState
object, not a bare boolean, and watchStateUpdated nests its payload under
watchState — there is no separate queueProcessingUpdate event; queue
changes are reported via messageQueueUpdated (body:
{count, isProcessing, oldestMessage, newestMessage}):
useEffect(() => {
// Initialize the watch connectivity service
const initWatchConnectivity = async () => {
try {
const state = await WatchConnectivity.initSession();
setIsWatchReachable(state.isReachable);
// If watch is reachable, check if there are queued messages to process
if (state.isReachable) {
const queueStatus = await WatchConnectivity.getQueueStatus();
if (queueStatus.count > 0) {
console.log(`Found ${queueStatus.count} queued messages, processing...`);
await WatchConnectivity.processMessageQueue();
}
}
} catch (error) {
console.error('Failed to initialize watch connectivity:', error);
}
};
initWatchConnectivity();
// Listen for watch reachability / session-state changes.
// Fires on activation, deactivate/inactive, sessionWatchStateDidChange, AND
// (since the reliability fixes) sessionReachabilityDidChange — so a pure
// reachability flip with no other state change now reaches JS promptly and
// triggers a queue drain, instead of requiring a poll.
const eventEmitter = new NativeEventEmitter(NativeModules.RNWatchConnectivityPro);
const watchStateSubscription = eventEmitter.addListener(
'watchStateUpdated',
(event) => {
const { isReachable, isPaired, isComplicationEnabled, queuedMessages } = event.watchState;
setIsWatchReachable(isReachable);
// Watch just became reachable - the library will automatically
// process any queued messages, but you can also handle it yourself
if (isReachable) {
console.log('Watch is now reachable, messages will be delivered');
} else {
console.log(`Watch is not reachable, ${queuedMessages ?? 0} message(s) queued`);
}
}
);
// Listen for queue changes directly
const queueUpdateSubscription = eventEmitter.addListener(
'messageQueueUpdated',
({ count, isProcessing }) => {
setIsProcessingQueue(isProcessing);
console.log(`Queue changed: ${count} message(s) pending, processing=${isProcessing}`);
}
);
return () => {
watchStateSubscription.remove();
queueUpdateSubscription.remove();
};
}, []);
Best Practices for Message Queueing
- Message Format: When sending messages, always include a timestamp and message type for easier management:
await WatchConnectivity.sendMessage({
type: 'DATA_UPDATE',
payload: yourData,
timestamp: Date.now() / 1000
});
- Prioritize Important Messages:
getQueueStatus()only returns aggregate info (count,isProcessing, and the enqueue timestamps of the oldest/newest entries) — it does NOT expose the contents ortypeof individual queued messages, so you cannot selectively clear "messages of this type." If a queue backlog means older messages are now stale, the practical pattern is to clear the whole queue and re-send the current state:
// When sending a new critical message that supersedes everything queued so far
const sendCriticalUpdate = async (data) => {
try {
const status = await WatchConnectivity.getQueueStatus();
if (status.count > 3) {
// Backlog is large enough that older queued messages are probably stale.
await WatchConnectivity.clearMessageQueue();
console.log('Cleared stale messages from queue');
}
// Now send the latest critical update
await WatchConnectivity.sendMessage({
type: 'CRITICAL_UPDATE',
payload: data,
timestamp: Date.now() / 1000
});
console.log('Critical update sent (or queued if watch unreachable)');
} catch (error: any) {
if (error?.code !== 'QUEUED') {
console.error('Failed to send critical update:', error);
}
}
};
- Handling Long-Disconnected Watches: If your app needs to handle scenarios where the watch might be disconnected for extended periods:
const syncWithWatch = async () => {
try {
// Check watch state first (checkWatchConnectivityStatus re-activates the
// session if needed and drains the queue if the watch turns out reachable)
const watchState = await WatchConnectivity.checkWatchConnectivityStatus();
if (!watchState.isPaired) {
// Watch is not paired at all - nothing to do
console.log('No watch paired with this iPhone');
return;
}
if (!watchState.isReachable) {
// Watch is paired but not reachable
console.log('Watch is paired but not reachable');
// Check queue status
const queueStatus = await WatchConnectivity.getQueueStatus();
if (queueStatus.count > 10) {
// Too many queued messages, consider clearing old ones
console.log('Queue has too many messages, clearing old messages');
await WatchConnectivity.clearMessageQueue();
// Send only the latest app state instead
await WatchConnectivity.updateApplicationContext({
lastSyncTime: Date.now() / 1000,
criticalData: getLatestAppData(),
syncStatus: 'QUEUE_CLEARED_RESYNCING'
});
console.log('Updated application context with latest state');
}
} else {
// Watch is reachable, we can send messages normally
console.log('Watch is reachable, sending updates');
// Regular message sending...
}
} catch (error) {
console.error('Error during watch sync:', error);
}
};
Application Context
// Update application context
const updateContext = async () => {
try {
await WatchConnectivity.updateApplicationContext({
currentScreen: 'home',
lastUpdated: Date.now(),
userSettings: { theme: 'dark', notifications: true }
});
console.log('Context updated successfully');
} catch (error) {
console.error('Failed to update context:', error);
}
};
// Get current application context
const getContext = async () => {
try {
const context = await WatchConnectivity.getApplicationContext();
console.log('Current context:', context);
} catch (error) {
console.error('Failed to get context:', error);
}
};
Watch Complications
Complications allow your app to display information directly on the Apple Watch face. The WatchConnectivity framework provides methods to update complications with high-priority transfers.
// Check if your app's complication is enabled on any watch face
const isEnabled = await WatchConnectivity.isComplicationEnabled();
// Check how many updates you have left for the day
// Apple limits the number of updates per day to preserve battery life
const remainingTransfers = await WatchConnectivity.getRemainingComplicationTransfers();
// Update the complication with new data
// This is delivered with higher priority than regular messages
const result = await WatchConnectivity.transferCurrentComplicationUserInfo({
value: 75, // Display value
unit: 'kW', // Display unit
trend: 'up', // Trend direction
timestamp: Date.now() // When the data was updated
});
// The result contains:
// {
// id: '1234-5678-...', // Unique transfer ID
// timestamp: 1234567890, // When the transfer was initiated
// isCurrentComplicationInfo: true, // Confirmation this is a complication update
// userInfo: {...} // The data you sent
// }
Your WatchKit app will need to implement the proper delegate methods to receive these updates and refresh the complication:
// In your ComplicationController.swift (watchOS app)
func getComplicationDescriptors(handler: @escaping ([CLKComplicationDescriptor]) -> Void) {
// List all your complications here
let descriptors = [
CLKComplicationDescriptor(identifier: "com.yourapp.complication",
displayName: "YourApp",
supportedFamilies: [.modularSmall, .graphicCircular])
]
handler(descriptors)
}
// In your ExtensionDelegate.swift (watchOS app)
func didReceiveComplicationUserInfo(_ userInfo: [String : Any]) {
// Request an update for all complications
let server = CLKComplicationServer.sharedInstance()
for complication in server.activeComplications ?? [] {
server.reloadTimeline(for: complication)
}
}
API Reference
The package has a single default export — there is no named
WatchConnectivity export, so import it as a default:
import WatchConnectivity from 'react-native-watch-connectivity-pro';
// Activate/query the session — there is no separate isAvailable()/getSessionState().
// initSession() and checkWatchConnectivityStatus() both resolve the full WatchState.
const state = await WatchConnectivity.initSession();
console.log(state.isPaired, state.isReachable, state.isWatchAppInstalled);
// Send a message (rejects with code 'QUEUED' if the watch isn't reachable —
// the message is added to the retry queue in that case, not dropped)
await WatchConnectivity.sendMessage({
command: 'update',
value: 42
});
// Update application context
await WatchConnectivity.updateApplicationContext({
lastUpdated: Date.now(),
settings: { /* ... */ }
});
// Transfer file
const fileTransfer = await WatchConnectivity.transferFile(
'/path/to/file.jpg',
{ type: 'image', timestamp: Date.now() }
);
// Watch complications
const isEnabled = await WatchConnectivity.isComplicationEnabled();
const remaining = await WatchConnectivity.getRemainingComplicationTransfers();
await WatchConnectivity.transferCurrentComplicationUserInfo({
value: 75,
unit: 'kW',
timestamp: Date.now()
});
// Toggle native file logging (Documents/watch_connectivity.log). Defaults to
// ON in Debug builds, OFF in Release — most apps never need to call this.
await WatchConnectivity.setFileLoggingEnabled(true);
Payload sanitization
Every outbound send path — sendMessage, updateApplicationContext,
transferUserInfo, transferCurrentComplicationUserInfo, replyToMessage
(and sendAuthTokensToWatch, which calls transferUserInfo under the hood)
— automatically runs your payload through the package's plistSafe
sanitizer before it reaches native. You do not need to hand-roll your own
stripping logic. plistSafe is also exported directly if you want to
pre-sanitize a payload yourself (e.g. before diffing it against a previous
context):
import { plistSafe } from 'react-native-watch-connectivity-pro';
const safe = plistSafe({ a: 1, b: undefined, c: NaN, d: [1, null, Infinity, 2] });
// => { a: 1, d: [1, 2] }
Semantics: undefined/null are stripped everywhere (object values and
array elements); non-finite numbers (NaN, Infinity, -Infinity) are
stripped, including when they appear as array elements; Date instances are
preserved as-is (a supported plist/NSDate type); other primitives pass
through untouched. Arrays keep their remaining elements densely packed —
dropped entries are removed, not left as holes — because WCSession payloads
cannot contain NSNull in an array either.
Common Issues & Troubleshooting
Messages not being received
If you're sending messages from the watch but not receiving them in your React Native app:
- Ensure you're using the
NativeEventEmitterand not directly callingaddListeneron the module - Initialize the watch connectivity early in your app lifecycle
- Make sure the delegate methods are properly implemented in the native module (they are in this package)
- Check that your watch app is properly sending messages
Handling Background/Foreground Transitions
Watch connectivity can report false negatives when your app transitions from background to foreground - meaning the watch may be reachable but the iOS API initially reports it as unreachable. This library includes enhanced functionality to handle this:
- Enhanced Reachability Detection: The
getReachabilitymethod now includes an active ping mechanism that attempts to wake up the WCSession when the app returns from background. This only works if your watch app actually replies to incoming messages — the ping is a realsendMessagecall with a 300ms reply timeout, and it's the watch's reply (any reply, content doesn't matter) that flipspingSucceededtotrue. If your watchOSWCSessionDelegatedoesn't implementsession(_:didReceiveMessage:replyHandler:)(or never calls the reply handler), the ping mechanism can never succeed andgetReachability/checkWatchConnectivityStatuswill just fall back to the rawisReachablevalue after the timeout (fromTimeout: true).
// Check watch reachability with enhanced detection
const checkWatchStatus = async () => {
try {
const status = await WatchConnectivity.getReachability();
console.log('Watch reachability status:', status);
if (status.pingSucceeded) {
console.log('Watch detected through ping mechanism!');
// The watch was initially reported as unreachable, but our ping found it
}
return status.isReachable;
} catch (error) {
console.error('Failed to check watch status:', error);
return false;
}
};
- Force Reactivation When App Resumes: Implement this pattern in your app to ensure reliable watch connectivity:
import { AppState } from 'react-native';
// In your component
useEffect(() => {
const handleAppStateChange = async (nextAppState) => {
// When app comes to foreground
if (nextAppState === 'active') {
console.log('App came to foreground, checking watch connectivity');
// Force a connectivity check which will use the enhanced ping mechanism
const status = await WatchConnectivity.checkWatchConnectivityStatus();
if (status.isReachable) {
console.log('Watch is reachable after app resumed');
// Refresh your watch data here
} else {
console.log('Watch not reachable after resuming app');
}
}
};
// Subscribe to app state changes
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => {
subscription.remove();
};
}, []);
- Multiple Retry Strategy: For the most robust implementation, use a retry pattern:
const sendDataToWatch = async (data) => {
// Try up to 3 times
for (let attempt = 0; attempt < 3; attempt++) {
try {
// First check reachability - this uses our enhanced ping mechanism
const status = await WatchConnectivity.getReachability();
if (!status.isReachable) {
console.log(`Watch not reachable on attempt ${attempt + 1}, waiting...`);
// Wait for 300ms before retrying
await new Promise(resolve => setTimeout(resolve, 300));
continue;
}
// Send the data
await WatchConnectivity.sendMessage({
type: 'apiData',
data: data,
timestamp: Date.now()
});
console.log('Successfully sent data to watch');
return true;
} catch (error) {
console.log(`Error on attempt ${attempt + 1}:`, error);
if (attempt < 2) {
// Wait before retrying
await new Promise(resolve => setTimeout(resolve, 300));
}
}
}
console.log('Failed to send data to watch after multiple attempts');
return false;
};
Handling Intermittent Connectivity
This library handles intermittent connectivity automatically by:
- Queueing messages when the watch is not reachable
- Automatically sending queued messages when the watch becomes reachable
- Providing APIs to monitor and manage the queue
To handle watch connectivity changes in your UI:
// Listen for watch state changes
const watchStateSubscription = eventEmitter.addListener(
'watchStateUpdated',
(event) => {
const { watchState } = event;
console.log('Watch state changed:', watchState);
// Update UI based on watch state
if (watchState.isReachable) {
// Show "Connected" status
} else {
// Show "Disconnected" status
}
// Check if there are queued messages
if (watchState.queuedMessages > 0) {
console.log(`${watchState.queuedMessages} messages queued`);
}
}
);
Implementation Details of Enhanced Reachability Detection
For developers interested in how the enhanced reachability detection works:
The Problem: Apple's WatchConnectivity framework's
isReachableproperty can report false negatives when an app returns from background to foreground, even when the watch app is active and running.Our Solution:
a. Active Ping Approach: Instead of relying only on the passive
isReachableproperty, our enhanced implementation:- First checks the standard
isReachableproperty - If it returns
false, we attempt to send a small "ping" message to the watch - If the ping succeeds, we know the watch is actually reachable despite what
isReachablereports - We use a short timeout to prevent hanging the app if the watch is truly unreachable
b. Reactivation Logic: When detecting that the app has come to the foreground from background:
- We force the WCSession to reactivate if needed
- We proactively check connectivity status using our ping approach
- We adjust a short delay to allow the WCSession to properly establish
- First checks the standard
Internal Sequence:
- App comes to foreground
AppStatechange is detectedcheckWatchConnectivityStatusis called- Enhanced
getReachabilitymethod is triggered - If
isReachableis false, a ping message attempts to wake up the connection - The result includes information about whether the ping succeeded
- The app can then make an informed decision about sending data
This approach solves the common issue where the watch appears to be unreachable immediately after the app returns from background, even though the watch app is open and running.
Session not activating
If the session isn't activating properly:
- Make sure both the iOS app and Watch app are installed and paired
- The Watch app must be installed from the app running on the phone
- Check watch reachability status before sending messages
Versioning policy
This package follows semver strictly on its public contract, not just
its type signatures: any change to a method name, an event name, an event
payload shape, or a rejection error code is a MAJOR version bump. Adding
new optional fields to an existing event body or result object, or adding a
new method/event, is MINOR. Bug fixes that don't change the shape of the
public contract (e.g. the queue-drain reliability fixes) are PATCH. See
CHANGELOG.md for the release history and
llms.txt for the exhaustive, quirk-by-quirk description of
current behavior.
License
MIT