A practical approach to implementing bidirectional communication between a React Native/Expo app and an Apple Watch app. Using Apple’s WatchConnectivity framework, React Native’s New Architecture, and TurboModules, this is a result of experimentation and exploration into cross-platform communication with React Native.
Versions and Relevant Files
- React Native: 0.76.5
- Expo SDK: 52.0.18
- React: 18.3.1
- iOS Target Platform: 15.1
- watchOS Target Platform: 9.6
- Xcode: 16.2
barlog/
├── src/
│ ├── native/
│ │ └── watch-connectivity/
│ │ └── specs/
│ │ └── NativeWatchConnectivity.ts # TurboModule specification
│ ├── WatchModule.ts # React Native main module interface
│ └── hooks/
│ └── useWatch.ts # React Native hook uses WatchModule
├── ios/
│ ├── WatchConnectivity.h # Native module header
│ ├── WatchConnectivity.m # Native module implementation
│ ├── AppDelegate.h # WCSession header
│ ├── AppDelegate.mm # WCSession setup and delegate
│ └── Watch Extension/
│ ├── ContentView.swift # Watch UI implementation
│ ├── WatchConnectivityManager.swift # Watch connectivity logic
│ └── NumberModel.swift # Simple Watch data model (optional)
Native Module Configuration
The native module uses React Native's codegen. Configuration in package.json:
{
"codegenConfig": {
"name": "RCTWatchConnectivitySpec",
"type": "modules",
"jsSrcsDir": "src/native/watch-connectivity/specs",
"android": {
"javaPackageName": "dev.keiver.barlog.watchconnectivity"
}
}
}
TurboModule Specification
This file defines the native module interface using JSI and TurboModule, making these methods available to React Native with strong typing and no bridge overhead.
NativeWatchConnectivity.ts:
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
readonly getConstants: () => {
WATCH_NUMBER_EVENT: string;
};
sendUpdateToWatch(update: {
weight?: number;
unit?: string;
label?: string;
logs?: string;
}): Promise<{ status: string }>;
addListener(eventName: string): void;
removeListeners(count: number): void;
}
export default TurboModuleRegistry.getEnforcing<Spec>(
'RCTWatchConnectivitySpec'
);
Watch Implementation
The main files in the watchOS extension are ContentView.swift and WatchConnectivityManager.swift. The first is the UI implementation, and the second is the connectivity logic.
import SwiftUI
import WatchKit
struct ContentView: View {
@StateObject private var connectivityManager = WatchConnectivityManager.shared
@StateObject private var model = NumberModel()
@State private var rotationValue: Double = 0
@State private var showHelp: Bool = false
@State private var displayLabel: String = "0"
@State private var lastUpdateTimestamp: TimeInterval = 0
@State private var unit: String = "lb"
@State private var lastLogs: String? = nil
@FocusState private var isFocused: Bool
let yellow = UIColor(red: 255.0 / 255.0, green: 183.0 / 255.0, blue: 3.0 / 255.0, alpha: 1.0)
let green = UIColor(red: 0.0 / 255.0, green: 255.0 / 255.0, blue: 0.0 / 255.0, alpha: 1.0)
var body: some View {
GeometryReader { geometry in
VStack {
Spacer()
// Yellow number for target weight
Text(displayLabel)
.font(.system(size: 45))
.foregroundColor(Color(yellow))
.frame(maxWidth: .infinity)
.focusable(true)
.focused($isFocused)
.shadow(radius: 5)
.digitalCrownRotation(
$rotationValue,
from: 0,
through: 800,
by: 1,
sensitivity: .medium,
isContinuous: false,
isHapticFeedbackEnabled: true
)
.onChange(of: rotationValue) { _ in
handleRotationChange()
}
// Add modern haptics if available
.modifier(SensoryFeedbackModifier(value: rotationValue))
// Green text for logs
if let logs = lastLogs {
Text(logs)
.font(.footnote)
.foregroundColor(Color(green))
.padding(10)
.font(.body)
.multilineTextAlignment(.center)
.padding(.top, 4)
// Actions under logs
HStack(spacing: 20) {
// Reset button
Button(action: resetTo95) {
Text("Reset")
.font(.caption2)
.foregroundColor(.blue)
}
.padding(.top, 12)
// Info button
Button(action: { showHelp.toggle() }) {
Image(systemName: "questionmark.circle")
.font(.caption2)
.foregroundColor(.blue)
}
.padding(.top, 12)
}
.padding(.horizontal, 17)
} else {
Text("Open app to see plates")
.padding(10)
.font(.body)
.multilineTextAlignment(.center)
}
Spacer()
// Phone connectivity status
if !connectivityManager.isReachable {
Text("Phone Not Connected")
.font(.footnote)
.foregroundColor(.red)
.padding(.bottom, 4)
}
}
.frame(width: geometry.size.width)
}
.edgesIgnoringSafeArea(.all)
.onAppear {
isFocused = true
}
.onReceive(connectivityManager.$receivedMessage) { message in
if let weight = message["weight"] as? Double {
if weight > 0 {
rotationValue = weight
}
}
if let l = message["label"] as? String, !l.isEmpty {
displayLabel = l
}
if let u = message["unit"] as? String {
unit = u
}
if let logs = message["logs"] as? String {
lastLogs = logs
}
}
.sheet(isPresented: $showHelp) {
HelpView()
}
}
...
React Native Integration
I'm using the useWatch.tsx hook to to manage the module's state and communication with the watch. This hook is used in the main component to send updates to the watch and receive events from it.
import { useEffect, useRef, useCallback } from 'react';
import WatchModule, { WatchUpdate, WatchNumberEvent } from '@/WatchModule';
import { EmitterSubscription } from 'react-native';
interface UseWatchConfig {
onNumberReceived?: (number: number) => void;
enabled?: boolean;
}
interface WatchState {
sendUpdate: (update: WatchUpdate) => void;
}
export function useWatch({
onNumberReceived,
enabled = true,
}: UseWatchConfig): WatchState {
const subscription = useRef<EmitterSubscription | { remove: () => void }>();
const isUserInteraction = useRef(false);
// Init watch listener
useEffect(() => {
if (!enabled) return;
subscription.current = WatchModule.addListener(
(event: WatchNumberEvent) => {
if (!event.number) return;
if (!isUserInteraction.current) {
onNumberReceived?.(event.number);
}
}
);
return () => {
if (subscription.current) {
subscription.current.remove();
}
};
}, [enabled, onNumberReceived]);
const sendUpdate = useCallback(
(update: WatchUpdate) => {
if (!enabled) return;
isUserInteraction.current = true;
console.log('Sending update to watch', update);
WatchModule.sendUpdateToWatch(update).finally(() => {
isUserInteraction.current = false;
});
},
[enabled]
);
return { sendUpdate };
}
Communication Flow
The communication between the React Native app and Apple Watch is bidirectional and event-driven, using Apple's WatchConnectivity framework.
From React Native to Watch:
- RootLayout.tsx triggers an update through useWatch hook
- WatchModule.ts sends message via TurboModule
- RCTWatchConnectivityModule sends message through WCSession
- WatchConnectivityManager.swift receives and publishes message
- ContentView.swift updates UI based on received data
From Watch to React Native:
- ContentView.swift triggers updates via Digital Crown
- WatchConnectivityManager.swift sends message via WCSession
- RCTWatchConnectivityModule receives and processes message
- WatchModule.ts emits event to JS
- useWatch hook notifies subscribers
%%{init: {
'theme': 'base',
'themeVariables': {
'clusterBkg': '#a3cb38',
'clusterBkg2': '#45aaf2',
'clusterBkg3': '#ffc914',
'fontFamily': 'arial',
'clusterBorder': '#333',
'nodeBorder': '#666'
},
'flowchart': {
'htmlLabels': true,
'curve': 'basis',
'nodeSpacing': 50,
'rankSpacing': 50,
'padding': 15
}
}}%%
flowchart TB
classDef default rx:14,ry:14,fill:#fff,color:#333,stroke:#666
classDef watchCluster fill:#a3cb38,color:#333,stroke:#333,rx:36,ry:36
classDef iosCluster fill:#45aaf2,color:#fff,stroke:#333,rx:36,ry:36
classDef rnCluster fill:#ffc914,color:#333,stroke:#333,rx:36,ry:36
subgraph Watch["Apple Watch"]
CV[ContentView.swift]
WM[WatchConnectivityManager.swift]
end
subgraph iOS["iOS Native"]
WCS[WCSession]
RCTM[RCTWatchConnectivityModule.mm]
end
subgraph RN["React Native"]
WModule[WatchModule.ts]
Root[RootLayout.tsx]
Hook[useWatch.ts]
end
%% React Native to Watch flow
Root --trigger update through hook--> Hook
Hook --send message via TurboModule--> WModule
WModule --send via native module--> RCTM
RCTM --send through WCSession--> WCS
WCS --receive and publish message--> WM
WM --publish receivedMessage--> CV
%% Watch to React Native flow
CV --Digital Crown updates--> WM
WM --send through WCSession--> WCS
WCS --receive and process--> RCTM
RCTM --emit via TurboModule--> WModule
WModule --notify subscribers--> Hook
Hook --update React Native UI--> Root
class Watch watchCluster
class iOS iosCluster
class RN rnCluster
Note: Both sides implement message queuing and retry mechanisms, while WatchConnectivityManager.swift and RCTWatchConnectivityModule.mm handle session lifecycle and reachability.
Conclusion
The new React Native architecture and TurboModules provide a robust and efficient way to implement native modules with strong typing and minimal bridge overhead. This approach to bidirectional communication between React Native and Apple Watch demonstrates its power and flexibility. I'm still used to the old bridge-based communication, but switching to TurboModules has been a great experience.
Please reach out if something should be done differently. I'm always open to suggestions and improvements. Thanks for reading!