In the third chapter, you'll extend your audio route detector to handle real-time updates. You'll add native event support so your app can respond automatically when the audio route changes — for example, when you plug in headphones or connect a Bluetooth speaker. You'll plan the event API, implement the listener logic in both Swift and Kotlin, and wire it up to React using event subscriptions.
- Add event support to your native module for real-time audio route changes.
- Connect native events to React using Expo’s listener and lifecycle hooks.
- Test the event flow on a physical device to ensure updates trigger correctly.
- Plan and define the event API in TypeScript
- Implement native event support in Swift and/or Kotlin
- Build the app and implement event listeners in React
- Test real-time audio route detection with different audio outputs
The first part of our module is now ready, and we can now query for the current audio route. In this exercise, we'll define the event types that will allow our module to notify JavaScript when the audio route changes.
Just as in Chapter 2, we'll start by defining the API that we would like to expose. We've already defined the available audio routes, so now we'll add the event payloads and the shape of the events our module will emit.
File: modules/expo-audio-route/src/ExpoAudioRoute.types.ts
Add the event type definitions to describe what data will be sent when the audio route changes:
export type AudioRoute = 'speaker' | 'wiredHeadset' | 'bluetooth' | 'unknown';
+export type RouteChangeEvent = {
+ route: AudioRoute;
+};
+
+export type ExpoAudioRouteModuleEvents = {
+ onAudioRouteChange: (params: RouteChangeEvent) => void;
+};The RouteChangeEvent type describes the data payload that will be sent with each event (the new audio route), and ExpoAudioRouteModuleEvents defines the event name and its handler signature.
Now we need to update our module declaration to include the event types. By adding ExpoAudioRouteModuleEvents as a generic parameter to NativeModule, we get full TypeScript support for our event listeners.
File: modules/expo-audio-route/src/ExpoAudioRouteModule.ts
Import the event types and pass them to the NativeModule generic:
import { NativeModule, requireNativeModule } from 'expo';
-import { AudioRoute } from './ExpoAudioRoute.types';
+import { AudioRoute, ExpoAudioRouteModuleEvents } from './ExpoAudioRoute.types';
-declare class ExpoAudioRouteModule extends NativeModule<{}> {
+declare class ExpoAudioRouteModule extends NativeModule<ExpoAudioRouteModuleEvents> {
getCurrentRouteAsync(): Promise<AudioRoute>;
}
// This call loads the native module object from the JSI.
export default requireNativeModule<ExpoAudioRouteModule>('ExpoAudioRoute');With this change, TypeScript will now know about the onAudioRouteChange event. We don't need to explicitly declare addListener or removeListener; these are already provided by Expo Modules with full type safety.
Now we'll implement the native side of event handling. This involves setting up observers that watch for audio route changes and send events to JavaScript when they occur.
Let's start with defining the event names that the module can send to JavaScript. This part is exactly the same in both Swift and Kotlin and it should be placed within your ModuleDefinitions:
Swift (modules/expo-audio-route/ios/ExpoAudioRouteModule.swift)
public func definition() -> ModuleDefinition {
Name("ExpoAudioRoute")
+ Events("onAudioRouteChange")
...
}Kotlin (modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteModule.kt)
override fun definition() = ModuleDefinition {
Name("ExpoAudioRoute")
+ Events("onAudioRouteChange")
...
}Note
If your module needs to send multiple events, you can separate them with commas: Events("onAudioRouteChange", "onSomethingElse")
Now we'll create a method to listen for audio route changes on the native side. Note that we're just defining the method here - we won't actually start observing yet. We'll activate it using Expo's lifecycle hooks in a later step.
Swift (iOS)
File: modules/expo-audio-route/ios/ExpoAudioRouteModule.swift
2.1 Add properties for notification handling
First, add two properties at the class level (outside of ModuleDefinition:
+private let notificationCenter: NotificationCenter = .default
+private var routeChangeObserver: NSObjectProtocol?
public func definition() -> ModuleDefinition { ... }notificationCenteris declared asletbecause it's a constant reference to the notification systemrouteChangeObserveris declared asvarbecause we need to store and potentially remove the observer later (it is initialized asnil)
2.2 Create the start observing method
Add this method outside of your ModuleDefinition, for example above your currentRoute() method:
private let notificationCenter: NotificationCenter = .default
private var routeChangeObserver: NSObjectProtocol?
public func definition() -> ModuleDefinition { ... }
+
+private func startObservingRouteChanges() {
+ func handleRouteChange(_: Notification) {
+ self.sendEvent("onAudioRouteChange", ["route": self.currentRoute()])
+ }
+
+ self.routeChangeObserver = NotificationCenter.default.addObserver(
+ forName: AVAudioSession.routeChangeNotification,
+ object: AVAudioSession.sharedInstance(),
+ queue: .main,
+ using: handleRouteChange
+ )
+}
private func currentRoute() -> String { ... }This method registers an observer that watches for route change notifications from the audio session. When a change occurs, it calls sendEvent() to notify JavaScript with the new route.
Kotlin (Android)
File: modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteModule.kt
2.1 Import android.media.AudioDeviceCallback
We import this so we can later create a callback object that overrides two methods Android provides for tracking audio device changes. Those methods fire when devices are added or removed, and we'll use them in an upcoming step to react to audio-route updates.
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import android.content.Context
import android.media.AudioDeviceInfo
import android.media.AudioManager
+import android.media.AudioDeviceCallback2.2 Add a property for the device callback
Add this property at the class level (outside of definition()), right below your audioManager declaration:
private var audioManager: AudioManager? = null
+private var deviceCallback: AudioDeviceCallback? = null
override fun definition() = ModuleDefinition { ... }We declare this as var because we'll assign it when we start observing and may need to unregister it later.
2.3 Create the start observing method
Add this method outside of your ModuleDefinition, for example above your currentRoute() method:
private var audioManager: AudioManager? = null
private var deviceCallback: AudioDeviceCallback? = null
override fun definition() = ModuleDefinition { ... }
+
+private fun startObservingRouteChanges() {
+ if (deviceCallback != null || audioManager == null) return
+
+ fun onChange() {
+ sendEvent("onAudioRouteChange", mapOf("route" to currentRoute()))
+ }
+
+ deviceCallback = object : AudioDeviceCallback() {
+ override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) = onChange()
+ override fun onAudioDevicesRemoved(removed: Array<out AudioDeviceInfo>) = onChange()
+ }
+
+ audioManager?.registerAudioDeviceCallback(deviceCallback, null)
+}
private fun currentRoute(): String { ... }This method creates an AudioDeviceCallback that watches for when audio devices are added or removed (like plugging in headphones or connecting Bluetooth). When either happens, it calls sendEvent() to notify JavaScript.
We also need a way to clean up when we're done observing. This prevents unnecessary callbacks and ensures proper resource cleanup when JavaScript stops listening.
Swift (iOS)
File: modules/expo-audio-route/ios/ExpoAudioRouteModule.swift
Add this method right after startObservingRouteChanges():
private func startObservingRouteChanges() { ... }
+private func stopObservingRouteChanges() {
+ if (routeChangeObserver == nil) {
+ return
+ }
+ notificationCenter.removeObserver(routeChangeObserver!)
+ routeChangeObserver = nil
+}
private func currentRoute() -> String { ... }It checks if an observer exists, and if so, removes it and sets routeChangeObserver back to nil to indicate we're no longer observing.
Kotlin (Android)
File: modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteModule.kt
Add this method right after startObservingRouteChanges():
private fun startObservingRouteChanges() { ... }
+private fun stopObservingRouteChanges() {
+ if (deviceCallback == null || audioManager == null) return
+ audioManager?.unregisterAudioDeviceCallback(deviceCallback)
+ deviceCallback = null
+}
private fun currentRoute(): String { ... }This unregisters our callback from the audio manager and sets deviceCallback back to null.
Finally, we connect the listening logic using OnStartObserving and OnStopObserving. These lifecycle hooks are called automatically by Expo Modules.
This ensures the native side only does work when needed (the system listeners are active only while JavaScript is subscribed) and from the JS side, the API remains as simple as using .addListener() and remove methods.
OnStartObservingruns automatically when the first JS listener is added with .addListener.OnStopObserving- runs automatically when the last JS listener is removed.
These methods should be placed within your ModuleDefinition, for example below getCurrentRouteAsync.
Swift (iOS)
AsyncFunction("getCurrentRouteAsync") { ... }
+OnStartObserving("onAudioRouteChange") {
+ self.startObservingRouteChanges()
+}
+
+OnStopObserving("onAudioRouteChange") {
+ self.stopObservingRouteChanges()
+}Kotlin (Android)
AsyncFunction("getCurrentRouteAsync") { ... }
+OnStartObserving("onAudioRouteChange") {
+ startObservingRouteChanges()
+}
+
+OnStopObserving("onAudioRouteChange") {
+ stopObservingRouteChanges()
+}Now that we've implemented the native event support, it's time to build the app and wire up the event listeners in React!
Since we changed native code, we need to rebuild. As before, we'll test on a physical device.
iOS:
npx expo run:ios --deviceAndroid:
npx expo run:android --deviceWhen prompted, select your connected device from the list.
Now let's implement the React side of event handling. We'll break this into clear steps.
Open App.tsx. We'll add an event listener that automatically updates the state when the audio route changes.
Inside the useEffect, call addListener to register for the onAudioRouteChange event.
Finally, return a cleanup function that removes the listener when the component unmounts. This ensures we properly unregister the listener and stop the native observers.
useEffect(() => {
const sub = ExpoAudioRoute.addListener('onAudioRouteChange', ({ route }) => {
setAudioRoute(route);
});
return () => {
sub.remove();
};
}, []);Note
- When you call
ExpoAudioRoute.addListener(), Expo Modules automatically calls theOnStartObservingmethod - The returned subscription object has a
remove()method, which is essentially an unsubscribe function - When you call
sub.remove(), Expo Modules automatically calls theOnStopObservingmethod - Remember:
OnStartObservingandOnStopObservingare just regular methods in your module definition - Expo Modules handles calling them at the right time based on JavaScript subscription state
Full solution
import { StatusBar } from 'expo-status-bar';
import { useEffect, useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import ExpoAudioRoute, { AudioRoute } from './modules/expo-audio-route';
export default function App() {
const [audioRoute, setAudioRoute] = useState<AudioRoute>('unknown');
useEffect(() => {
const sub = ExpoAudioRoute.addListener('onAudioRouteChange', ({ route }) => {
setAudioRoute(route);
});
return () => {
sub.remove();
};
}, []);
return (
<View style={styles.container}>
<StatusBar style="auto" />
<Text>Current Route: {audioRoute}</Text>
<Button
title="Get Audio Route"
onPress={async () => {
const route = await ExpoAudioRoute.getCurrentRouteAsync();
setAudioRoute(route);
}}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});With the app running on your device, test the automatic event detection:
Test 1: Initial state
-
Open the app - you should see
"unknown"displayed. This is the initial state value we set in the code, not from an event. -
Important: The event listener typically does not send an initial event when you first register it. Events are only emitted when the audio route changes. However, on Android you may receive an immediate event because onAudioDevicesAdded is called with existing devices when the callback is first registered.
-
To verify this behavior, add a
console.loginside your event listener to see when events are actually emitted:
const sub = ExpoAudioRoute.addListener('onAudioRouteChange', ({ route }) => {
console.log('Audio route changed to:', route);
setAudioRoute(route);
});- Check your terminal - on iOS you won't see any log until you actually change the audio route in the next tests, but on Android you may see an initial log.
Test 2: Connect Bluetooth
- Connect a Bluetooth audio device (e.g. headphones)
- Watch the display update automatically to
"bluetooth"without pressing any button - The event listener detected the change and updated the state!
Test 3: Wired headphones
- Disconnect the Bluetooth device
- Connect wired headphones
- The display should automatically update to
"wiredHeadset"
Test 4: Back to speaker
- Disconnect all external audio devices
- The display should automatically return to
"speaker"
Note
👀 Try it: Unlike Chapter 2 where you had to press a button to check the route, now the app automatically responds to audio route changes in real-time! This is the power of event-driven architecture.
chapter-3-vid.mp4
Want to see exactly when the native observers start and stop?
Store the listener in a reference and add/remove it by clicking buttons. This lets you manually control when the observer starts and stops.
Set breakpoints in Xcode or Android Studio to observe when the getCurrentRouteAsync, OnStartObserving and OnStopObserving methods are executed.
import { useState, useRef } from 'react';
import { Button, Text, View } from 'react-native';
import ExpoAudioRoute, { type AudioRoute } from './modules/expo-audio-route';
import type { EventSubscription } from 'expo-modules-core';
export default function App() {
const [currentRoute, setCurrentRoute] = useState<AudioRoute>('unknown');
const routeChangeSubscriptionRef = useRef<EventSubscription | null>(
null
);
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text>Audio Route: {currentRoute}</Text>
<Button
title="Get current route"
onPress={() => {
ExpoAudioRoute.getCurrentRouteAsync().then((route) => {
setCurrentRoute(route);
});
}}
/>
<Button
title="Register for route changes"
onPress={() => {
if (routeChangeSubscriptionRef.current) return;
routeChangeSubscriptionRef.current = ExpoAudioRoute.addListener(
'onAudioRouteChange',
({ route }) => {
setCurrentRoute(route);
}
);
}}
/>
<Button
title="Unregister for route changes"
onPress={() => {
routeChangeSubscriptionRef.current?.remove();
routeChangeSubscriptionRef.current = null;
}}
/>
</View>
);
}
