In the fourth chapter, you'll refine your Expo module to make it cleaner, more convenient, and easier to use. You'll replace manual event subscriptions with Expo’s useEvent and useEventListener hooks, simplify your React code, and expose your module as custom hooks for even better developer experience. Finally, you’ll add a simple web fallback so the module behaves gracefully on unsupported platforms.
- Improve usability by integrating Expo’s event hooks and creating custom React hooks.
- Streamline event handling and state management in React.
- Add a fallback implementation for unsupported platforms like web.
- Simplify event handling using Expo's built-in hooks
- Wrap the module as custom React hooks for better developer experience
- Add a web fallback implementation for graceful degradation
We now have a working Expo Module where we can query for the current audio route and listen to changes. But there are refinements we can make to improve the developer experience when consuming our module.
Currently, we're handling events like this:
useEffect(() => {
const sub = ExpoAudioRoute.addListener('onAudioRouteChange', ({ route }) => {
setAudioRoute(route);
});
return () => {
sub.remove();
};
}, []);There's nothing wrong with this approach, but Expo provides built-in hooks that can make our code cleaner and more concise.
Without any changes to our native code, we can use the useEventListener hook instead. This hook automatically handles registering and unregistering the listener for us, eliminating the need for manual cleanup.
File: App.tsx
Replace your useEffect event handling with:
-import { useEffect, useState } from 'react';
+import { useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import ExpoAudioRoute, { AudioRoute } from './modules/expo-audio-route';
+import { useEventListener } from 'expo';
export default function App() {
const [audioRoute, setAudioRoute] = useState<AudioRoute>('unknown');
+ useEventListener(ExpoAudioRoute, 'onAudioRouteChange', ({ route }) => {
+ setAudioRoute(route);
+ });
- useEffect(() => {
- // Registers an event listener for audio route changes
- const sub = ExpoAudioRoute.addListener(
- 'onAudioRouteChange',
- ({ route }) => {
- setAudioRoute(route);
- }
- );
-
- return () => {
- // Unregisters the event listener
- sub.remove();
- };
- }, []);
return (
<>
...
</>
);
}This is much cleaner! The hook manages the subscription lifecycle automatically.
Note
👀 Try it: Save and test your app. It should work exactly the same as before, but with less boilerplate code.
There's an even more elegant approach using useEvent that eliminates the need for state management entirely. The useEvent hook both listens for events and manages the state for you.
File: App.tsx
-import { useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import ExpoAudioRoute, { AudioRoute } from './modules/expo-audio-route';
-import { useEventListener } from 'expo';
+import { useEvent } from 'expo';
+ const initialRoute: AudioRoute = 'unknown';
export default function App() {
- const [audioRoute, setAudioRoute] = useState<AudioRoute>('unknown');
-
- useEventListener(ExpoAudioRoute, 'onAudioRouteChange', ({ route }) => {
- setAudioRoute(route);
- });
+ const { route } = useEvent(ExpoAudioRoute, 'onAudioRouteChange', {
+ route: initialRoute,
+ });
return (
<>
<View style={styles.container}>
+ <Text>{route}</Text>
- <Text>{audioRoute}</Text>
- <Button
- title="Get Audio Route"
- onPress={() => {
- ExpoAudioRoute.getCurrentRouteAsync().then((route) => {
- setAudioRoute(route);
- });
- }}
- />
</View>
</>
);
}Now route is automatically updated whenever the event fires, and you don't need to manage state manually!
There's one catch with useEvent, it only updates when the event fires, meaning you won't see the current route until it changes. Let's fix this by dispatching the current route immediately when a listener is created.
This requires just one line of native code; we'll send an event as soon as OnStartObserving is called.
Swift (iOS)
File: modules/expo-audio-route/ios/ExpoAudioRouteModule.swift
OnStartObserving("onAudioRouteChange") {
+ self.sendEvent("onAudioRouteChange", ["route": self.currentRoute()])
self.startObservingRouteChanges()
}Kotlin (Android)
File: modules/expo-audio-route/android/src/main/java/expo/modules/audioroute/ExpoAudioRouteModule.kt
OnStartObserving("onAudioRouteChange") {
+ sendEvent("onAudioRouteChange", mapOf("route" to currentRoute()))
startObservingRouteChanges()
}Since we changed native code, rebuild your app:
npx expo run:ios --device
# or
npx expo run:android --deviceNow when your app launches, the current audio route will appear immediately, and it will still update automatically when you connect or disconnect audio devices!
Full solution with `useEvent`
import { StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { useEvent } from 'expo';
import ExpoAudioRoute from './modules/expo-audio-route';
import type { AudioRoute } from './modules/expo-audio-route';
const initialRoute: AudioRoute = 'unknown';
export default function App() {
const { route } = useEvent(ExpoAudioRoute, 'onAudioRouteChange', {
route: initialRoute,
});
return (
<>
<StatusBar style="auto" />
<View style={styles.container}>
<Text>Current Route: {route}</Text>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});We've come a long way with our module, but we can make it even easier to consume. With just a few TypeScript changes, we can expose the module as custom React hooks, following React best practices and conventions.
By wrapping our module in a function called useAudioRoute(), we're creating a hook. It might seem like a small change, but this unlocks a better developer experience.
Let's start by wrapping our module in a function that follows the hook naming convention.
File: modules/expo-audio-route/src/ExpoAudioRouteModule.ts
import { NativeModule, requireNativeModule } from 'expo';
import { AudioRoute, ExpoAudioRouteModuleEvents } from './ExpoAudioRoute.types';
declare class ExpoAudioRouteModule extends NativeModule<ExpoAudioRouteModuleEvents> {
getCurrentRouteAsync(): Promise<AudioRoute>;
}
-export default requireNativeModule<ExpoAudioRouteModule>('ExpoAudioRoute');
+const nativeModule = requireNativeModule<ExpoAudioRouteModule>('ExpoAudioRoute');
+
+export function useAudioRoute() {
+ return nativeModule;
+}
+
+export default nativeModule;File: modules/expo-audio-route/index.ts
export { default } from './src/ExpoAudioRouteModule';
export * from './src/ExpoAudioRoute.types';
+export { useAudioRoute } from './src/ExpoAudioRouteModule';Now developers can use your module as a hook:
const audioRoute = useAudioRoute();
// Query the current route
audioRoute.getCurrentRouteAsync().then((route) => {
console.log('Current Route:', route);
});
// Listen to events
useEffect(() => {
const sub = audioRoute.addListener('onAudioRouteChange', ({ route }) => {
console.log('Current Route:', route);
});
return () => sub.remove();
}, []);Or with useEvent like this:
const audioRoute = useAudioRoute();
const { route } = useEvent(audioRoute, 'onAudioRouteChange', {
route: initialRoute,
});Note
👀 Try it: Update your App.tsx to use useAudioRoute() instead of directly importing the module. The functionality should work exactly the same, this change is purely about changing the API.
Full solution
import { useEffect } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { useAudioRoute, AudioRoute } from './modules/expo-audio-route';
export default function App() {
const audioRoute = useAudioRoute();
useEffect(() => {
// Registers an event listener for audio route changes
const sub = audioRoute.addListener('onAudioRouteChange', ({ route }) => {
console.log(route);
});
return () => {
// Unregisters the event listener
sub.remove();
};
}, []);
return (
<>
<StatusBar style="auto" />
<View style={styles.container}>
<Button
title="Get Audio Route"
onPress={() => {
audioRoute.getCurrentRouteAsync().then((route) => {
console.log(route);
});
}}
/>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});Let's take it further by creating a hook specifically for handling audio route changes. This will encapsulate all the event handling logic.
File: modules/expo-audio-route/src/ExpoAudioRouteModule.ts
First, add the import for useEvent at the top:
-import { NativeModule, requireNativeModule } from 'expo';
+import { NativeModule, requireNativeModule, useEvent } from 'expo';
import { AudioRoute, ExpoAudioRouteModuleEvents } from './ExpoAudioRoute.types';Then add the new hook:
const nativeModule = requireNativeModule<ExpoAudioRouteModule>('ExpoAudioRoute');
export function useAudioRoute() {
return nativeModule;
}
+const initialRoute: AudioRoute = 'unknown';
+
+export function useAudioRouteChangedEvent() {
+ return useEvent(nativeModule, 'onAudioRouteChange', {
+ route: initialRoute,
+ });
+}
export default nativeModule;File: modules/expo-audio-route/index.ts
export { default } from './src/ExpoAudioRouteModule';
export * from './src/ExpoAudioRoute.types';
-export { useAudioRoute } from './src/ExpoAudioRouteModule';
+export { useAudioRoute, useAudioRouteChangedEvent } from './src/ExpoAudioRouteModule';Now consuming your module becomes much simpler. The hook returns the current route directly, so you no longer need useState, useEffect, or the manual subscription cleanup.
File: App.tsx
import { StatusBar } from 'expo-status-bar';
import { Button, StyleSheet, Text, View } from 'react-native';
import ExpoAudioRoute, { AudioRoute, useAudioRouteChangedEvent } from './modules/expo-audio-route';
const initialRoute: AudioRoute = 'unknown';
export default function App() {
const { route } = useAudioRouteChangedEvent();
return (
<View style={styles.container}>
<StatusBar style="auto" />
<Text>Current Route: {route}</Text>
<Button
title="Get Audio Route"
onPress={async () => {
const route = await ExpoAudioRoute.getCurrentRouteAsync();
console.log(route);
}}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});We're keeping the imperative Button here so you can compare both APIs side by side, but the route is now coming entirely from useAudioRouteChangedEvent, no listener registration, no state management, just a hook.
Note
👀 Try it: Save and test your app. The audio route should display immediately and update automatically when you connect or disconnect audio devices. All that functionality in one line!
With this implementation, developers can now consume your API in multiple ways depending on their needs:
- Direct module access:
await ExpoAudioRoute.getCurrentRouteAsync();- Hook-based access:
const audioRoute = useAudioRoute();
await audioRoute.getCurrentRouteAsync();- Event hook:
const { route } = useAudioRouteChangedEvent();This flexibility makes your module accessible for different coding styles and use cases.
If your module doesn't support a specific platform (or can't support it due to platform limitations), it's good practice to provide a fallback implementation. This prevents crashes and provides a graceful degradation experience.
Let's add web support to our application with a fallback implementation.
First, add the necessary packages for web support:
npx expo install react-dom react-native-webStart the dev server and press w to open the web version:
npx expo start --clearPress w in the terminal. Your browser will open, but the app will be blank with this error in the console:
Uncaught Error: Cannot find native module 'ExpoAudioRoute'Stop the dev server with Ctrl+C before editing the module config, the platform list is read at startup and won't pick up changes on a hot reload.
In Chapter 2, we regenerated the module with only apple and android selected, so expo-module.config.json doesn't list web yet. Add it now so the web fallback is picked up:
File: modules/expo-audio-route/expo-module.config.json
{
- "platforms": ["apple", "android"],
+ "platforms": ["apple", "android", "web"],
"apple": {
"modules": ["ExpoAudioRouteModule"]
},
"android": {
"modules": ["expo.modules.audioroute.ExpoAudioRouteModule"]
}
}create-expo-module already generated a ExpoAudioRouteModule.web.ts for us when we regenerated the module in Chapter 2, but it's an empty stub. Replace its contents with a fallback implementation that mirrors the native API:
File: modules/expo-audio-route/src/ExpoAudioRouteModule.web.ts
import { registerWebModule, NativeModule } from 'expo';
import { AudioRoute } from './ExpoAudioRoute.types';
class ExpoAudioRouteModule extends NativeModule<{}> {
async getCurrentRouteAsync(): Promise<AudioRoute> {
return Promise.resolve('unknown');
}
}
const webModule = registerWebModule(
ExpoAudioRouteModule,
'ExpoAudioRouteModule'
);
export function useAudioRouteChangedEvent(): { route: AudioRoute } {
return { route: 'unknown' };
}
export function useAudioRoute() {
return webModule;
}
export default webModule;This implementation:
- Provides the same API as the native modules
- Returns
'unknown'for all audio route queries - Exposes the same hooks for consistency
Restart the dev server so Metro picks up the new platform list and the updated fallback module:
npx expo start --clearPress w again, and your browser should now display:
Current Route: unknown