Skip to main content

Watch Connectivity

WatchConnectivity is a typed JavaScript facade over Apple's WCSession. It exposes the same API on both sides of the pairing — the iOS host app and the watchOS app import the same module and call the same methods. The native side picks the right WCSessionDelegate callbacks based on which platform it's running on.

This makes Watch Connectivity the primary way to:

  • Send live messages between the phone and the watch.
  • Sync a shared key/value context that survives launches.
  • Queue background user-info transfers that the OS delivers FIFO.
  • Move binary payloads.
  • React to reachability and session state changes.

The module is shipped as a TurboModule with codegen, so types stay in sync with the native implementation.

Import

import { WatchConnectivity } from '@appsent-co/react-native-watchos/watch-connectivity';

The exact same import works in your iOS app's entrypoint and in your watchOS entrypoint (e.g. index.watchos.tsx).

Activating the session

Activation is required before any send/receive call. It's idempotent — calling it twice is safe — so the convention is to call it once on mount.

import { useEffect } from 'react';
import { WatchConnectivity } from '@appsent-co/react-native-watchos/watch-connectivity';

useEffect(() => {
WatchConnectivity.activate().then((state) => {
console.log('reachable?', state.isReachable);
});
}, []);

activate() resolves once activation has been requested. The real activated state arrives asynchronously — subscribe to 'stateChanged' if you need to know the moment it flips.

Sending and receiving messages

Live messages are the simplest primitive: best-effort, no queueing, peer must be reachable.

Fire-and-forget

// iOS side
await WatchConnectivity.sendMessage({ type: 'refresh' });
// Watch side
WatchConnectivity.on('message', ({ content }) => {
if (content.type === 'refresh') {
// re-fetch data
}
});

Request / reply

Set expectReply: true and the promise resolves with the peer's returned value. On the other side, return a value from the handler — the facade hides replyId plumbing.

// iOS side — ping the watch
const reply = await WatchConnectivity.sendMessage(
{ ping: Date.now() },
{ expectReply: true, timeoutMs: 5_000 }
);
console.log('pong:', reply);
// Watch side — respond with whatever the peer expects
WatchConnectivity.on('message', ({ content }) => {
if ('ping' in content) {
return { pong: content.ping };
}
});

The handler can also return a promise — async work (e.g. a database read) is awaited before the reply is sent.

WatchConnectivity.on('message', async ({ content }) => {
if (content.type === 'load-user') {
return await fetchUser(content.id);
}
});
Timeouts

WCSession has no built-in reply timeout. The facade rejects the promise after timeoutMs (default 30,000 ms) when expectReply is true. Pass timeoutMs: 0 to disable.

Binary payloads

For binary data, use the *Data variants. Payloads are base64-encoded on the wire — callers handle encoding/decoding.

const base64 = btoa(String.fromCharCode(...bytes));
await WatchConnectivity.sendMessageData(base64);

WatchConnectivity.on('messageData', ({ data }) => {
const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
// ...
});

Application context

Application context is a last-write-wins persistent dictionary. The OS delivers the latest value to the peer, the value survives app restarts, and it's queryable immediately after activation.

Use it for app-wide state the watch should always know (current user, theme, sync token).

// iOS — push the latest context
await WatchConnectivity.updateApplicationContext({
user: 'maxence',
theme: 'dark',
ts: Date.now(),
});
// Watch — read the most recently delivered context
const ctx = await WatchConnectivity.getReceivedApplicationContext();

// or subscribe to updates
WatchConnectivity.on('applicationContext', (ctx) => {
console.log('new context:', ctx);
});

getApplicationContext() returns what this side last pushed (may not have been delivered yet). getReceivedApplicationContext() returns what was last received from the peer.

User info transfers

User info transfers are queued, FIFO, reboot-safe dictionaries. The OS delivers them in the background when the peer is reachable — use them for events you can't afford to drop (analytics, syncs, log entries).

// Enqueue
const { id } = await WatchConnectivity.transferUserInfo({
event: 'workout-completed',
duration: 1820,
});

// Inspect the outbound queue
const pending = await WatchConnectivity.outstandingUserInfoTransfers();
console.log(`${pending.length} transfers awaiting delivery`);
// Receive on the peer
WatchConnectivity.on('userInfo', (info) => {
recordEvent(info);
});

Reachability and state

WatchConnectivity.on('reachabilityChanged', (reachable) => {
setReachable(reachable);
});

WatchConnectivity.on('stateChanged', (state) => {
console.log('activation:', state.activationState);
});

// Snapshot
const state = await WatchConnectivity.getState();

SessionState shape:

FieldTypeNotes
activationState'notActivated' | 'inactive' | 'activated'
isReachablebooleanPeer is currently reachable for live messages
isPairedbooleaniOS-only — always false on watch
isWatchAppInstalledbooleaniOS-only — always false on watch
isCompanionAppInstalledbooleanWatch-only — always false on iOS

Subscriptions

on(event, handler) returns a Subscription with a .remove() method. Always remove subscriptions in cleanup.

useEffect(() => {
const subs = [
WatchConnectivity.on('reachabilityChanged', setReachable),
WatchConnectivity.on('message', handleMessage),
WatchConnectivity.on('applicationContext', handleContext),
];
return () => subs.forEach((s) => s.remove());
}, []);

Available events:

EventPayloadCan reply?
message{ content: Dictionary, replyId? }Yes — return a Dictionary
messageData{ data: string (base64), replyId? }Yes — return a base64 string
applicationContextDictionaryNo
userInfoDictionaryNo
reachabilityChangedbooleanNo
stateChangedSessionStateNo

Worked example — phone ↔ watch ping

A complete two-sided setup. The phone pings, the watch replies with the round-trip timestamp.

// App.tsx (iOS side)
import { useEffect, useState } from 'react';
import { Button, Text, View } from 'react-native';
import { WatchConnectivity } from '@appsent-co/react-native-watchos/watch-connectivity';

export default function App() {
const [reachable, setReachable] = useState(false);
const [lastPong, setLastPong] = useState<number | null>(null);

useEffect(() => {
WatchConnectivity.activate().then((s) => setReachable(s.isReachable));
const sub = WatchConnectivity.on('reachabilityChanged', setReachable);
return () => sub.remove();
}, []);

const ping = async () => {
const reply = await WatchConnectivity.sendMessage(
{ ping: Date.now() },
{ expectReply: true, timeoutMs: 5_000 }
);
setLastPong((reply?.pong as number) ?? null);
};

return (
<View>
<Text>Reachable: {String(reachable)}</Text>
<Button title="Ping watch" onPress={ping} />
{lastPong != null && <Text>Last pong: {lastPong}</Text>}
</View>
);
}
// index.watchos.tsx (watch side)
import { useEffect } from 'react';
import { VStack, Text } from '@appsent-co/react-native-watchos';
import { WatchConnectivity } from '@appsent-co/react-native-watchos/watch-connectivity';

export default function WatchApp() {
useEffect(() => {
WatchConnectivity.activate();
const sub = WatchConnectivity.on('message', ({ content }) => {
if ('ping' in content) {
return { pong: content.ping };
}
});
return () => sub.remove();
}, []);

return (
<VStack>
<Text>Listening for pings…</Text>
</VStack>
);
}

API reference

MethodDescription
activate()Activate WCSession.default. Idempotent.
getState()Snapshot of the current session state.
sendMessage(msg, opts?)Send a live JSON message. Set expectReply to await a reply.
sendMessageData(base64, opts?)Binary equivalent. Payload is base64-encoded.
updateApplicationContext(ctx)Push the last-write-wins shared context.
getApplicationContext()The context this side last pushed.
getReceivedApplicationContext()The latest context received from the peer.
transferUserInfo(info)Enqueue a guaranteed, FIFO background delivery.
outstandingUserInfoTransfers()List transfers this side enqueued but the peer hasn't ack'd.
on(event, handler)Subscribe to an event. Returns { remove() }.

Source: src/watchConnectivity/.