
Learn how to integrate custom native modules into your React Native app using the Expo Bare Workflow to extend functionality beyond Expo's managed capabilities.
As a full-stack and React Native developer, I've always appreciated how Expo simplifies mobile app development. It handles so much of the native complexity, letting us focus on the JavaScript and UI. For many projects, especially those starting fresh, the Expo managed workflow is a dream come true.
However, there comes a point in almost every seasoned React Native developer's journey where the managed workflow's boundaries become apparent. You might need to integrate with a very specific hardware SDK, a niche third-party library that doesn't have a React Native wrapper, or access a deep-level native API that Expo hasn't exposed yet. This is where custom native modules come into play, and often, it means transitioning to what we call the Expo Bare Workflow.
If you've ever felt limited by Expo and wondered how to tap into the full power of native Android and iOS, you're in the right place. In this post, I'll walk you through understanding, creating, and integrating your own custom native modules within an Expo Bare Workflow project.
The Expo managed workflow is fantastic for rapid development. It abstracts away the android and ios directories, allowing you to build and run your app without ever touching Xcode or Android Studio directly. This is achieved by bundling your JavaScript with a pre-built native shell that includes a wide range of common native modules (like camera, location, notifications, etc.) provided by the Expo SDK.
But what happens when you need something not in the Expo SDK? For instance, maybe you're building an app that needs to interface with a custom Bluetooth device using a proprietary SDK, or access a specific biometric API only available through a native library. In the managed workflow, you can't add arbitrary native code or link new native libraries because you don't have direct access to the native project files.
This is the primary reason to transition to the Expo Bare Workflow. When you go bare, Expo generates the android and ios directories for you, essentially giving you a standard React Native project setup, but with the added benefits of still being able to use Expo tools like EAS Build, Expo Go (for development without native modules), and the Expo SDK modules you still want to use.
Tagged with
Leave a comment
At its core, a React Native native module is a way to execute native code (Java/Kotlin for Android, Objective-C/Swift for iOS) from your JavaScript code. Think of it as a bridge:
This bridging mechanism is what allows React Native apps to achieve near-native performance and access platform-specific features that aren't exposed through JavaScript alone.
There are two main paths to get an Expo project with native directories:
Start a new Bare Workflow project:
npx create-expo-app --template expo-template-bare-minimum my-native-app
cd my-native-app
This command scaffolds a new project with the android and ios directories already present, ready for native development.
Migrate an existing Managed Workflow project: If you already have an Expo managed project, you can generate the native directories using:
expo prebuild
This command will create the android and ios folders, effectively converting your managed project into a bare-like structure while still allowing you to use most Expo SDK features.
For the purpose of this guide, let's assume you have an Expo project with the android and ios directories available.
Let's create a simple native module called CalendarModule that will expose a function to get the current date as a string from the native side. This is a trivial example, but it clearly demonstrates the core concepts.
First, open your project in Android Studio (open the android folder).
Create the Native Module File:
Inside android/app/src/main/java/com/yourprojectname (replace com/yourprojectname with your actual package name, e.g., com/mynativeapp), create a new Java class file named CalendarModule.java.
// android/app/src/main/java/com/mynativeapp/CalendarModule.java
package com.mynativeapp;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Callback;
import com.facebook.react.bridge.Promise;
import android.util.Log;
import java.util.Date;
import java.text.SimpleDateFormat;
import java.util.Locale;
public class CalendarModule extends ReactContextBaseJavaModule {
CalendarModule(ReactApplicationContext context) {
super(context);
}
@Override
public String getName() {
return "CalendarModule";
}
@ReactMethod
public void createCalendarEvent(String name, String location, Promise promise) {
try {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault());
String currentDate = sdf.format(new Date());
Log.d("CalendarModule", "Create event called with name: " + name + " and location: " + location + ". Current date: " + currentDate);
promise.resolve("Event " + name + " at " + location + " created on " + currentDate);
} catch (Exception e) {
promise.reject("E_CREATE_EVENT_FAILED", "Could not create event: " + e.getMessage());
}
}
@Override
public java.util.Map<String, Object> getConstants() {
final java.util.Map<String, Object> constants = new java.util.HashMap<>();
constants.put("DEFAULT_EVENT_NAME", "My New Event");
return constants;
}
}
getName(): Returns the name of the module that will be used in JavaScript.@ReactMethod: Annotates a method to be exposed to JavaScript. Here, createCalendarEvent takes arguments and uses a Promise to return a result or an error.getConstants(): Allows you to expose constants to JavaScript.Create a React Package:
Next, create a new Java class file named CalendarPackage.java in the same directory.
// android/app/src/main/java/com/mynativeapp/CalendarPackage.java
package com.mynativeapp;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class CalendarPackage implements ReactPackage {
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();
modules.add(new CalendarModule(reactContext));
return modules;
}
}
This package groups your native modules and view managers.
Register the Package:
Open android/app/src/main/java/com/yourprojectname/MainApplication.java and add your CalendarPackage to the getPackages() list.
// android/app/src/main/java/com/mynativeapp/MainApplication.java
package com.mynativeapp;
import android.app.Application;
import android.content.Context;
import android.net.Uri;
import com.facebook.react.PackageList;
import com.facebook.react.ReactApplication;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
import com.facebook.react.shell.MainReactPackage;
import com.facebook.soloader.SoLoader;
import java.lang.reflect.InvocationTargetException;
import java.util.List;
import java.util.Arrays;
public class MainApplication extends Application implements ReactApplication {
private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
@Override
public boolean getUseDeveloperSupport() {
return BuildConfig.DEBUG;
}
@Override
protected List<ReactPackage> getPackages() {
@SuppressWarnings("UnnecessaryLocalVariable")
List<ReactPackage> packages = new PackageList(this).getPackages();
// Packages that cannot be autolinked yet can be added manually here, for example:
// packages.add(new MyReactNativePackage());
packages.add(new CalendarPackage()); // <-- Add your package here!
return packages;
}
@Override
protected String getJSMainModuleName() {
return "index";
}
};
@Override
public ReactNativeHost getReactNativeHost() {
return mReactNativeHost;
}
@Override
public void onCreate() {
super.onCreate();
SoLoader.init(this, /* native exopackage */ false);
initializeFlipper(this, getReactNativeHost().getReactInstanceManager()); // Remove this line if you don't want Flipper enabled
}
/**
* Loads Flipper in `debug` build type. Assumes Flipper library has been added to the `devDependencies` in `package.json`.
* @param context
* @param reactInstanceManager
*/
private static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
if (BuildConfig.DEBUG) {
try {
/*
We use reflection here to pick up the class that initializes Flipper,
since Flipper library is not available in release code
*/
Class<?> aClass = Class.forName("com.mynativeapp.ReactNativeFlipper");
aClass
.getMethod("initializeFlipper", Context.class, ReactInstanceManager.class)
.invoke(null, context, reactInstanceManager);
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
}
}
}
Now, let's set up the iOS part. Open your project's ios folder in Xcode.
Create the Native Module Files:
In Xcode, right-click on your project name in the Project Navigator, then choose New File.... Select Objective-C File and name it CalendarModule.
Xcode will ask if you want to create a bridging header. Choose Create Bridging Header if it's a new project or if you don't have one. This is crucial if you plan to mix Objective-C and Swift, or use Swift in your module.
CalendarModule.h:
// ios/CalendarModule.h
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
@interface CalendarModule : RCTEventEmitter <RCTBridgeModule>
@end
CalendarModule.m:
// ios/CalendarModule.m
#import "CalendarModule.h"
#import <React/RCTLog.h>
@implementation CalendarModule
// To export a module named CalendarModule
RCT_EXPORT_MODULE();
// Example method, exposed to JavaScript
RCT_EXPORT_METHOD(createCalendarEvent:(NSString *)name location:(NSString *)location resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
{
NSDateFormatter *dateFormatter=[[NSDateFormatter alloc] init];
[dateFormatter setDateFormat:@"yyyy-MM-dd HH:mm:ss"];
NSString *currentDate = [dateFormatter stringFromDate:[NSDate date]];
RCTLogInfo(@"Pretending to create an event %@ at %@. Current date: %@", name, location, currentDate);
if (name) {
resolve([NSString stringWithFormat:@"Event %@ at %@ created on %@", name, location, currentDate]);
} else {
reject(@"E_CREATE_EVENT_FAILED", @"Event name cannot be empty", nil);
}
}
// To expose constants to JavaScript
- (NSDictionary *)constantsToExport
{
return @{ @"DEFAULT_EVENT_NAME": @"My New Event" };
}
// We don't want to export any events for this simple module
- (NSArray<NSString *> *)supportedEvents
{
return @[];
}
@end
RCT_EXPORT_MODULE(): Registers the module with React Native, making it available via NativeModules.CalendarModule.RCT_EXPORT_METHOD(): Exposes a method to JavaScript. The arguments resolver and rejecter are for Promises.constantsToExport: Similar to Android's getConstants(), this exposes constants.Now that the native modules are set up, let's access them from our React Native JavaScript code.
Open App.js or any other component where you want to use the module.
// App.js
import React from 'react';
import { SafeAreaView, StyleSheet, Text, Button, NativeModules, Alert } from 'react-native';
const { CalendarModule } = NativeModules;
function App() {
const onCreateEventPress = async () => {
try {
const eventName = CalendarModule.DEFAULT_EVENT_NAME; // Accessing a constant
const result = await CalendarModule.createCalendarEvent(
eventName,
'My Awesome Location'
);
Alert.alert('Event Created', result);
} catch (e) {
Alert.alert('Error', e.message);
console.error(e);
}
};
return (
<SafeAreaView style={styles.container}>
<Text style={styles.title}>Native Module Example</Text>
<Text style={styles.description}>Using CalendarModule to create an event.</Text>
<Button title="Create Calendar Event" onPress={onCreateEventPress} />
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F5FCFF',
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 20,
},
description: {
fontSize: 16,
textAlign: 'center',
marginHorizontal: 30,
marginBottom: 30,
},
});
export default App;
After making these changes, rebuild your native apps. For Android, run npx react-native run-android or build from Android Studio. For iOS, run npx react-native run-ios or build from Xcode. You should now see the button, and pressing it will trigger your native module!
The example above used simple strings and Promises. React Native's bridge supports a variety of data types:
ReadableArray (Android) or NSArray (iOS).ReadableMap (Android) or NSDictionary (iOS).When dealing with more complex data, you'll often serialize it to JSON strings on the JavaScript side, pass it to native, deserialize, perform operations, and then serialize the result back to a JSON string before returning it to JavaScript. This ensures compatibility across the bridge.
Testing is crucial when working with native modules:
node_modules and Metro Cache: Sometimes, when making native changes, you might encounter stale caches. Running rm -rf node_modules && npm install && npx expo start --clear can often resolve these.LogCat (Android) and RCTLogInfo (iOS): For debugging, use native logging mechanisms to see what your native code is doing.android and ios directories to version control. Treat them as integral parts of your project.Once you've integrated your native modules, the publishing process for a bare workflow app differs from the managed workflow. You can no longer use the classic expo build command.
The recommended approach is to use EAS Build. EAS (Expo Application Services) is designed to handle builds for both managed and bare workflow projects, including those with custom native modules. It streamlines the process of generating app binaries (.apk, .aab for Android; .ipa for iOS) in the cloud, handling signing, provisioning profiles, and more.
Alternatively, you can build manually using Android Studio for Android and Xcode for iOS, just like you would with a vanilla React Native project. However, EAS Build significantly reduces the complexity and setup involved.
Embracing the Expo Bare Workflow and integrating custom native modules opens up a world of possibilities for your React Native applications. While it adds a layer of complexity by requiring you to dive into platform-specific code, it's a powerful skill that allows you to overcome the limitations of purely JavaScript-based development and build truly custom, high-performance mobile experiences.
Don't let the idea of native code intimidate you. Start with simple modules, experiment, and gradually build up your expertise. The ability to bridge JavaScript with native power will make you a much more versatile and capable mobile developer.
Do you have a unique mobile app idea that requires deep native integration? Or are you looking for a full-stack developer to bring your vision to life? Feel free to get in touch – I'd love to help you build something amazing.