TODO: Make an awesome introduction. The one below is a draft
You love Expo. But sometimes you have to add some native code. With Expo Unimodules you can make your custom native code reusable between any Expo app, and with a little more effort, between any React Native app. What's more - the native code will be loaded automatically when you do yarn install, without the need of any additional linking. And in this article I'll show you how to.
I assume you are using Mac - however it's not necessary unless you follow the iOS part. Also, some terminal commands I use are Mac-specific.
TODO: What they are, idea, types of modules, packages, interfaces, singletons, exported modules etc.
@sjchmiela awesome presentation: https://www.youtube.com/watch?v=-9CJZRv7uOY
"Exported" Universal Modules are like RN modules, but on steroids. Differences:
- Only one-time installation is required for all of them. And when using Expo (bare workflow), this step is already done.
- Each module doesn't require additional installation besides
yarn install. - They can depend on each other on the native side thanks to the built-in API
- What else??
If you are already using Expo with bare workflow, you are done. If you are using React Native, but without any Expo modules, then you'll have to follow these instructions.
For simplicity, I'll create a plain new Expo bare workflow app:
yarn global add expo-cli
expo init MyBareApp --template expo-template-bare-typescript
cd MyBareAppYou can do it anywhere, it should be inside your project directory*, but it doesn't have to. There are also a few ways to link it to your app project:
- Yarn/NPM linking
- Yarn workspaces (assuming you created it under project directory)
- Publishing to NPM
- ... ?
*A module which is outside a project's directory will not be able to build as standalone project, because it uses
reactandreact-nativeas peerDependencies. You can change that inpackage.json, but it's not recommended. Also, it's native part is included into your app's project doing gradle build or pod install and it's only able to build as part of that project (regardless of its location)
For the sake of this tutorial, I'll create it inside my project directory and cover both Yarn linking and Yarn workspaces.
mkdir MyNativeModules && cd MyNativeModulesExpo CLI has a special command dedicated to generating new modules. It takes a module template and injects names you specify at generation. Default template is fetched NPM expo-module-template@latest, but you can specify your custom template as a path to local directory or NPM package.
Because default template is prepared work with Expo internal module tools - it will not work out of the box. We have two options: we can either do some preparations manually to make it working, or the quick and lazy way: I've done the job already and published module template working out of the box. It's available here.
Let's generate a module using that custom template. First, we need to clone it somewhere outside the MyNativeModules (to avoid further problems).
git clone https://github.com/barthap/expo-module-template.git
cd expo-module-template
pwd | pbcopyThe latter command copies current path to clipboard, it's helpful but optional. Now we need to cd into MyBareApp/MyNativeModules and do
expo generate-module --template "$(pbpaste)"Module generation command will now ask you a few questions about names. I'll call my module just HelloWorld. I provided:
- How would you like to call your module in JS/npm?:
hello-world - How would you like to call your module in CocoaPods?:
EXHelloWorld - How would you like to call your module in Java?:
my.modules.helloworld - How would you like to call your module in JS/TS codebase:
HelloWorld - Would you like to create a NativeViewManager?:
n- you can typeyif you want, but we're not going to use it now.
Now your module is generated and should have the following file structure, assuming you are using Expo CLI 3.25.2 or newer:
hello-world
+-- build
+-- android
| +-- src/main
| | +-- my/modules/helloworld
| | | +-- HelloWorldModule.kt
| | | +-- HelloWorldPackage.kt
| | +-- AndroidManifest.xml
| +-- src/test/my/modules/helloworld
| | +-- HelloWorldModuleTest.kt
| +-- build.gradle
+-- ios
| +-- EXHelloWorld
| | +-- EXHelloWorldModule.h
| | +-- EXHelloWorldModule.m
| +-- EXHelloWorld.podspec
+-- src
| +-- __tests__
| | +-- HelloWorld-test.ts
| +-- HelloWorld.ts
| +-- HelloWorld.types.ts
| +-- ExpoHelloWorld.ts
| +-- ExpoHelloWorld.web.ts
+-- package.json
+-- unimodule.json
+-- ... [other files: babel config, eslint config, tsconfig etc.]
Using older Expo CLI may result in that files under
srcare named differently. Files shown above starting withExpoprefix would be generated without that prefix, andHelloWorld.tswould be calledUnimodule.ts. That's because CLI expected our module name to start withexpo-prefix, e.g.expo-hello-world.
The structure is very simillar to all modules across Expo world. There are android and ios directories containing native source files, a src directory with TypeScript files, and a build folder with compiled JS. Worth noticing is also unimodule.json - this file defines, which platforms our module is targeting.
To build our newly-created module, just do:
cd hello-world && yarnNow, our module is ready to be included into our application.
In this section I'll be doing the whole configuration from scratch. If you have already done it using my custom template, it's still worth reading to have better knowledge about how it works. Some part of this configuration, like jest, eslint, prettier is not necessary, but it's recommended, because we want our module to be clean, and every Expo module is using those tools.
Let's generate our module using default template:
cd MyBareApp/MyNativeModules
expo generate-module
cd hello-worldAnswer the questions the same way as above. Generated module would also have the same structure. First thing is to add some dependencies:
yarn add -D expo-module-scripts eslint prettierPacage expo-module-scripts makes our life easier - it automates a few processes for us - it comes with CLI command that automates building, linting and testing. However, it's optional, you can still configure everything manually.
Since
expo-module-scriptsis rarerly published, it may have outdated dependencies. You may verify them usingnpm view expo-module-scriptsand comparing to the latest masterpackage.json. That's why I prefer installing them directly:yarn add --dev typescript ts-jest jest-expo eslint-config-universe.
In package.json we need to update the scripts section to use expo-module-scripts CLI. We should also add jest preset:
{
"scripts": {
"build": "expo-module build",
"clean": "expo-module clean",
"lint": "expo-module lint",
"test": "expo-module test",
"prepare": "expo-module prepare",
"prepublishOnly": "expo-module prepublishOnly",
"expo-module": "expo-module"
},
"jest": {
"preset": "expo-module-scripts"
}
...Note for Windows users:
expo-module-scriptsuses bash scripts and thus does not work on Windows unless you run it using WSL. If you do not want to, you'll have to writescriptssection manually inpackage.json.
A few words of explaination:
buildscript runstsc --watchunder the hood and compiles TS fromsrcinto JS insidebuilddir. It's good to have it running while editing your module TS code.cleanremovesbuilddirectorylintrunseslintunder the hood to look for errors (it includes prettier checks)testrunsjestin watch mode. Runs tests for all platform presets.prepareis part of package lifecycle and is ran automatically afteryarn install. It runscleanandbuildinternally.
For more information about those scripts, see its docs.
It's highly recommended (and required by current eslint preset) to use prettier. We need to create a .prettierrc file and fill it with example configuration:
{
"printWidth": 100,
"tabWidth": 2,
"singleQuote": true,
"jsxBracketSameLine": true,
"trailingComma": "es5"
}If you want to customize your linting configuration, worth mentioning is also eslint-config-universe which contains eslint and prettier presets used in Expo.
We're now ready to check if our scripts are working properly. To ensure everything is configured, we can also run expo-module configure, which generates missing config files:
npx expo-module configure
yarn clean
yarn build
# Ctrl+C to exit watch mode in build
yarn lint
yarn test
# Press Q to exit watch mode in testsIf there are no errors, we are ready to start writing code. There may be prettier warnings, you can fix them by running yarn lint --fix.
The module consists of three worlds: User-facing JS/TS, Android and iOS native. There's also web, but it's pretty straightforward - code is written directly in TypeScript.
Remember to have
yarn buildrunning while editing module's TypeScript code!
Take a look at src/HelloWorld.ts. This is the main file, which by default exports module API to your apps. Currently, it has one demo method:
export async function someGreatMethodAsync(options: SampleOptions) {
return await ExpoHelloWorld.someGreatMethodAsync(options);
}It returns result of another function of the same name (of course it doesn't have to be the same name) which is imported from ExpoHelloWorld. There are two implementations of this module: ExpoHelloWorld.ts and ExpoHelloWorld.web.ts. First one is bundled when running on Android and iOS, and the latter one, when running Web. When we open ExpoHelloWorld.ts, we can see that it just reexports it from NativeModulesProxy - that means the native counterpart is called here. In contrast to the native one, Web version has direct TypeScript implementation.
Important note: When you import TypeScript module, e.g. ExpoHelloWorld, a bundler can inject different file depending on target platform it's bundling for:
ExpoHelloWorld.android.tsfor AndroidExpoHelloWorld.ios.tsfor iOSExpoHelloWorld.native.tsfor Android or iOS if respective file above doesn't existExpoHelloWorld.web.tsfor WebExpoHelloWorld.tsif none of above exist
Let's rename and modify this function to better observe the results:
export async function myAwesomeMethodAsync(options: SampleOptions) {
console.log('Calling someGreatMethodAsync()...');
const msgFromNative = await ExpoHelloWorld.someGreatMethodAsync(options);
return "Received: " + msgFromNative;
}If you already have experience with React Native custom native modules (Android and iOS), this part should look familiar to you, because Expo API is very simillar to the one known from RN.
One new thing is Module Registry. It's an interface from @unimodules/core, which contains list of all registered unimodules in application. It allows you to access one module from another, e.g. Permissions from Camera, using interfaces.
Example: We have three modules:
Camera,PermissionsInterfaceandPermissions, which implements that interface. When Camera needs to ask for user permissions, it asks Module Registry to find module implementing PermissionsInterface and then it can use that implementation.
The ios/EXHelloWorld directory consists of two files: EXHelloWorldModule.h and EXHelloWorldModule.m which are Objective-C implementation of our module. The .h file declares our module as @interface:
@interface EXHelloWorldModule : UMExportedModule <UMModuleRegistryConsumer>
@endNote that it inherits from UMExportedModule - base class for all unimodules exported to JavaScript.
The EXHelloWorldModule.m contains implementation of our module. I've modified it a bit for the needs of this article:
@interface EXHelloWorldModule ()
@property (nonatomic, weak) UMModuleRegistry *moduleRegistry;
@end
@implementation EXHelloWorldModule
UM_EXPORT_MODULE(ExpoHelloWorld);
- (void)setModuleRegistry:(UMModuleRegistry *)moduleRegistry
{
_moduleRegistry = moduleRegistry;
}
UM_EXPORT_METHOD_AS(someGreatMethodAsync,
options:(NSDictionary *)options
resolve:(UMPromiseResolveBlock)resolve
reject:(UMPromiseRejectBlock)reject)
{
NSString* optionValue = [options objectForKey:@"someOption"];
resolve([NSString stringWithFormat:@"Hello from iOS! Option value: %@", optionValue]);
}
@endNotice two macros used here: UM_EXPORT_MODULE(), which marks our module as exportable, and UM_EXPORT_METHOD_AS() which defines methods to be exported to JavaScript.
Our someGreatMethodAsync is exported to JS (and there imported from NativeModulesProxy) exactly from this place. It passes our JS options parameter as NSDictionary*. The latter two parameters: resolve and reject are blocks to be called, when we want our JS method to return promise result.
Important: Each native method called from JS is async and it's native execution should either call
resolveorreject! Otherwise our app can hang waiting for promise to respond.
When opened android/src/main/java/my/modules/helloworld, there are two Kotlin files:
HelloWorldModule.kt and HelloWorldPackage.java. The module file is just a Java/Kotlin class, which inherits from org.unimodules.core.ExportedModule. This is how it looks like, with slight modifications:
class HelloWorldModule(context: Context) : ExportedModule(context) {
private var mModuleRegistry: ModuleRegistry? = null
override fun getName(): String {
return "ExpoHelloWorld"
}
override fun onCreate(moduleRegistry: ModuleRegistry) {
mModuleRegistry = moduleRegistry
}
@ExpoMethod
fun someGreatMethodAsync(options: Map<String, Any>, promise: Promise) {
val optionValue = options.getOrDefault("someOption", "unspecified")
promise.resolve("Hello from Android! Option value: $optionValue")
}
}As you can see, our API method is marked here as @ExpoMethod which means it will be exported to the JS code. We also have getName() which is the only mandatory function that needs to be overriden. The string returned is a module name - the one which we refer to in JS, importing from NativeModulesProxy.
Although Kotlin is recommended, you can still use Java if you prefer to.
The simplest way would be to add local directory as dependency to our App's package.json and run yarn install. And that's awesome, but we'd have to do it every time we modify our module. Let's use a different approach:
You can find more info here, I'll just show how to do it quickly in our case.
Open your app's MyBareApp/package.json and apply the following:
{
"private": true,
"workspaces": ["MyNativeModules/*"],
"dependencies": {
"hello-world": "*"
}
}Save the file and run yarn. Now your MyBareApp/node_modules/hello-world is a symlink to a MyBareApp/MyNativeModules/hello-world. This means that each change done in your module is automatically reflected in your app.
You can find more information about Yarn links here. I'll just show the quick way
cd MyBareApp/MyNativeModules/hello-world
yarn link
cd ../../
yarn link hello-world
yarn installNow you achieved the same result as in case of using Yarn workspaces. This approach has one advantage - your unimodule package can be anywhere on your disk, it doesn't have to be relative to your App project.
Open any component in your app and try your module out. I'll show the minimal example:
import React from 'react';
import { StyleSheet, Text, View, Button } from 'react-native';
import * as HelloWorld from 'hello-world';
export default function App() {
const [hello, setHello] = React.useState('None yet');
const loadHelloMessage = async () => {
const helloMsg = await HelloWorld.myAwesomeMethodAsync({
someOption: '🚀'
});
setHello(helloMsg);
}
return (
<View style={styles.container}>
<Text>Click to show message</Text>
<Button onPress={loadHelloMessage} title="Press me" />
<Text>Message: {hello}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});Now run your app:
# Android
npx react-native run-android
# iOS
npx pod-install
npx react-native run-iosWatch installation process - the hello-world module should appear somewhere on the unimodule list. How this happens? That's simple - build script (CocoaPods or Gradle) crawls node_modules (or others if specified explicitly) looking for packages containing unimodule.json. When one is found, it is attached to the project. Then (TODO what)
When your app is running, press the button and see response coming from your native code.
Your new module is easily reusable. Everything you have to do is:
- Create your app, or use existing.
- For non-expo apps: install unimodules.
- Import your module by either:
- Using yarn workspaces
- Using npm/yarn linking
- Just installing from NPM (if you've published your module)
- Adding dependency as local path in your
package.json
- (on iOS) Run
npx pod-install - Run your app!
It's THAT simple! No modifications in native code are required. This simple process works for any module from Expo, since all of them are Unimodules.
I also encourage you to explore existing modules' source code and see how they are done.
TODO: Describe problems encountered below:
- My TypeScript files generated
Unimodule.tsinstead of real name - Fix: update Expo CLI expo/expo-cli#2548 - After changing my Kotlin source code, I got an error
Duplicate class XXXin my module. - Fix is coming (expo/expo#10007). Updatereact-native-unimoduleswhen it's published. - Android error
:unimodules-test-corenot found or sth like that - FIX is going to be discussed - that package is not published yet. I can publish my own fork. But you can comment out this dependency inbuild.gradlebut unit tests won't work. - My NPM/yarn scripts don't work on Windows (
expo-module-scriptscrashes) - Use WSL or replaceexpo-module-scriptsby callingtsc,eslint,jestdirectly. Take a look ateslint-config-universefor nice presets. - My module is not building because of missing
reactandreact-nativedependencies - It won't build. You either use it inside react-based project or yarn workspace having that project, or add these dependencies topackage.json.
Now when know how everything works, we can do a tiny bit more sophisticated demo - a simple message box/alert. (??? or something else?)
- Show events and constants
- Show ViewManager etc
- ReadableArguments i Bundle
- Other UM Core functionalities?
- Unit testing
Inter-module ops
- Using singleton modules
- Creating and using module interfaces
- Adapters (rn-adapter)
- Detailed architecture of each modules
- ???
Also: Swift in custom modules? check