Skip to content

Latest commit

 

History

History
567 lines (419 loc) · 15.5 KB

File metadata and controls

567 lines (419 loc) · 15.5 KB

Chapter 4

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.

Goals

  • 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.

Tasks

  • 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

Exercises

Exercise 0: Improve event handling with Expo hooks

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.

Tasks

1. Simplify with useEventListener

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.

2. Eliminate state management with useEvent

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!

3. Send initial value immediately

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()
}

4. Rebuild and test

Since we changed native code, rebuild your app:

npx expo run:ios --device
# or
npx expo run:android --device

Now 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',
  },
});

Exercise 1: Create custom React hooks

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.

Tasks

1. Create a basic module hook

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',
  },
});

2. Create a specialized event hook

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';

3. Use the new hook in your app

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.

Bonus Exercise: Add web fallback support

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.

Tasks

1. Install web dependencies

First, add the necessary packages for web support:

npx expo install react-dom react-native-web

2. Test the web app without a fallback

Start the dev server and press w to open the web version:

npx expo start --clear

Press 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'

3. Add web to the module's supported platforms

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"]
  }
}

4. Update the web fallback module

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

5. Test the web fallback

Restart the dev server so Metro picks up the new platform list and the updated fallback module:

npx expo start --clear

Press w again, and your browser should now display:

Current Route: unknown

Next exercise

Chapter 5 👉