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 MyBareApp
You 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
react
andreact-native
as 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 MyNativeModules
Expo 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 | pbcopy
The 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 typey
if 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
src
are named differently. Files shown above starting withExpo
prefix would be generated without that prefix, andHelloWorld.ts
would 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 && yarn
Now, 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-world
Answer 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 prettier
Pacage 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-scripts
is rarerly published, it may have outdated dependencies. You may verify them usingnpm view expo-module-scripts
and 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-scripts
uses 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 writescripts
section manually inpackage.json
.
A few words of explaination:
build
script runstsc --watch
under the hood and compiles TS fromsrc
into JS insidebuild
dir. It's good to have it running while editing your module TS code.clean
removesbuild
directorylint
runseslint
under the hood to look for errors (it includes prettier checks)test
runsjest
in watch mode. Runs tests for all platform presets.prepare
is part of package lifecycle and is ran automatically afteryarn install
. It runsclean
andbuild
internally.
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 tests
If 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 build
running 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.ts
for AndroidExpoHelloWorld.ios.ts
for iOSExpoHelloWorld.native.ts
for Android or iOS if respective file above doesn't existExpoHelloWorld.web.ts
for WebExpoHelloWorld.ts
if 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
,PermissionsInterface
andPermissions
, 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>
@end
Note 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]);
}
@end
Notice 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
resolve
orreject
! 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 install
Now 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-ios
Watch 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.ts
instead of real name - Fix: update Expo CLI expo/expo-cli#2548 - After changing my Kotlin source code, I got an error
Duplicate class XXX
in my module. - Fix is coming (expo/expo#10007). Updatereact-native-unimodules
when it's published. - Android error
:unimodules-test-core
not 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.gradle
but unit tests won't work. - My NPM/yarn scripts don't work on Windows (
expo-module-scripts
crashes) - Use WSL or replaceexpo-module-scripts
by callingtsc
,eslint
,jest
directly. Take a look ateslint-config-universe
for nice presets. - My module is not building because of missing
react
andreact-native
dependencies - 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