Adding Apple Watch App to React Native app, bidirectional communication

By Keiver, on 12/19/2024, about: iOS, React Native, WCSession, Expo, WatchConnectivity, SwiftUI, TurboModule, JSI, Native Modules, Digital Crown, WatchKit, Cross-Platform Communication

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.

Apple Watch Screenshot Apple Watch Screenshot Apple Watch Screenshot Apple Watch Screenshot

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:

  1. RootLayout.tsx triggers an update through useWatch hook
  2. WatchModule.ts sends message via TurboModule
  3. RCTWatchConnectivityModule sends message through WCSession
  4. WatchConnectivityManager.swift receives and publishes message
  5. ContentView.swift updates UI based on received data

From Watch to React Native:

  1. ContentView.swift triggers updates via Digital Crown
  2. WatchConnectivityManager.swift sends message via WCSession
  3. RCTWatchConnectivityModule receives and processes message
  4. WatchModule.ts emits event to JS
  5. 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!

contact@keiver.dev

More Resources

  1. Apple Watch WatchConnectivity
  2. React Native
  3. React Native Codegen
  4. Project Repository