Module API
Note: This API is still experimental and subject to change. Some features that you need may not be implemented yet.
The Expo module API provided by expo-modules-core
is an abstraction layer on top of React Native modules that helps you build native modules in modern languages (Swift and Kotlin) with an easy to use and convenient API that is consistent across platforms where possible.
After several years of maintaining over 50 native modules in the Expo SDK, we have found out that many issues that have arisen were caused by unhandled null values or using incorrect types. Modern language features can help developers avoid these bugs; for example, the lack of optional types combined with the dynamism of Objective-C made it tough to catch certain classes of bugs that would have been caught by the compiler in Swift.
This is one of the reasons why the Expo module ecosystem was designed from the ground up to be used with modern native languages: Swift and Kotlin.
Another big pain point that we have encountered is the validation of arguments passed from JavaScript to native functions. This is especially error prone, time consuming, and difficult to maintain when it comes to
NSDictionary
or
ReadableMap
, where the type of values is unknown in runtime and each property needs to be validated separately by the developer. A valuable feature of the Expo module API is that it has full knowledge of the argument types the native function expects, so it can pre-validate and convert the arguments for you! The dictionaries can be represented as native structs that we call
Records. Knowing the argument types, it is also possible to
automatically convert arguments to some platform-specific types (e.g.
{ x: number, y: number }
or
[number, number]
can be translated to CoreGraphics's
CGPoint
for your convenience).
import ExpoModulesCore
public class MyModule: Module {
public func definition() -> ModuleDefinition {
}
}
package my.module.package
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class MyModule : Module() {
override fun definition() = ModuleDefinition {
}
}
Add your classes to iOS and/or Android
modules
in the
module config.
{
"ios": {
"modules": ["MyModule"]
},
"android": {
"modules": ["my.module.package.MyModule"]
}
}
On iOS you also need to run pod install
to link the new class.
On Android it will be linked automatically before building.
Now that the class is set up and linked, you can start to add functionality. The rest of this guide is a reference for the module API, along with links to
examples of simple to moderately complex real-world modules that you can reference to better understand how to use the API.
As you might have noticed in the snippets above, each module class must implement the definition
function. The definition consists of components that describe the module's functionality and behavior.
Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument. Can be inferred from module's class name, but it's recommended to set it explicitly for clarity.
Sets constant properties on the module. Can take a dictionary or a closure that returns a dictionary.
Constants([
"PI": Double.pi
])
Constants {
return [
"PI": Double.pi
]
}
Constants(
"PI" to kotlin.math.PI
)
Constants {
return@Constants mapOf(
"PI" to kotlin.math.PI
)
}
Defines a native synchronous function that will be exported to JavaScript. Synchronous means that when the function is executed in JavaScript, its native code is run on the same thread and blocks further execution of the script until the native function returns.
- name:
String
— Name of the function that you'll call from JavaScript. - body:
(args...) -> ReturnType
— The closure to run when the function is called.
The function can receive up to 8 arguments. This is due to the limitations of generics in both Swift and Kotlin, because this component must be implemented separately for each arity.
Function("syncFunction") { (message: String) in
return message
}
Function("syncFunction") { message: String ->
return@Function message
}
import { requireNativeModule } from 'expo-modules-core';
const MyModule = requireNativeModule('MyModule');
function getMessage() {
return MyModule.syncFunction('bar');
}
Defines a JavaScript function that always returns a Promise
and whose native code is by default dispatched on the different thread than the JavaScript runtime runs on.
- name:
String
— Name of the function that you'll call from JavaScript. - body:
(args...) -> ReturnType
— The closure to run when the function is called.
If the type of the last argument is Promise
, the function will wait for the promise to be resolved or rejected before the response is passed back to JavaScript. Otherwise, the function is immediately resolved with the returned value or rejected if it throws an exception.
The function can receive up to 8 arguments (including the promise).
It is recommended to use AsyncFunction
over Function
when it:
- does I/O bound tasks such as sending network requests or interacting with the file system
- needs to be run on different thread, e.g. the main UI thread for UI-related tasks
- is an extensive or long-lasting operation that would block the JavaScript thread which in turn would reduce the responsiveness of the application
AsyncFunction("asyncFunction") { (message: String, promise: Promise) in
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
promise.resolve(message)
}
}
AsyncFunction("asyncFunction") { message: String, promise: Promise ->
launch(Dispatchers.Main) {
promise.resolve(message)
}
}
import { requireNativeModule } from 'expo-modules-core';
const MyModule = requireNativeModule('MyModule');
async function getMessageAsync() {
return await MyModule.asyncFunction('bar');
}
Defines event names that the module can send to JavaScript.
Events("onCameraReady", "onPictureSaved", "onBarCodeScanned")
Events("onCameraReady", "onPictureSaved", "onBarCodeScanned")
Defines the factory creating a native view, when the module is used as a view.
On Android, the factory function also receives
Android Context which is required to create any view.
View { context ->
TextView(context)
}
Defines a setter for the view prop of given name.
- name:
String
— Name of view prop that you want to define a setter. - setter:
(view: ViewType, value: ValueType) -> ()
— Closure that is invoked when the view rerenders.
This property can only be used within a
ViewManager
closure.
Prop("background") { (view: UIView, color: UIColor) in
view.backgroundColor = color
}
Prop("background") { view: View, @ColorInt color: Int ->
view.setBackgroundColor(color)
}
Note: Props of function type (callbacks) are not supported yet.
Enables the module to be used as a view manager. The view manager definition is built from the definition components used in the closure passed to
viewManager
. Definition components that are accepted as part of the view manager definition:
View
,
Prop
.
ViewManager {
View {
UIView()
}
Prop("isHidden") { (view: UIView, hidden: Bool) in
view.isHidden = hidden
}
}
ViewManager {
View { context ->
View(context)
}
Prop("isHidden") { view: View, hidden: Bool ->
view.isVisible = !hidden
}
}
Defines module's lifecycle listener that is called right after module initialization. If you need to set up something when the module gets initialized, use this instead of module's class initializer.
Defines module's lifecycle listener that is called when the module is about to be deallocated. Use it instead of module's class destructor.
Defines the function that is invoked when the first event listener is added.
Defines the function that is invoked when all event listeners are removed.
Defines module's lifecycle listener that is called when the app context owning the module is about to be deallocated.
Defines the listener that is called when the app is about to enter the foreground mode.
Defines the listener that is called when the app enters the background mode.
Defines the listener that is called when the app becomes active again (after OnAppEntersForeground
).
Defines the listener that is called when the activity owning the JavaScript context is about to be destroyed.
Fundamentally, only primitive and serializable data can be passed back and forth between the runtimes. However, usually native modules need to receive custom data structures — more sophisticated than just the dictionary/map where the values are of unknown (Any
) type and so each value has to be validated and casted on its own. The Expo module API provides protocols to make it more convenient to work with data objects, to provide automatic validation, and finally, to ensure native type-safety on each object member.
Convertibles are native types that can be initialized from certain specific kinds of data received from JavaScript. Such types are allowed to be used as an argument type in Function
's body. For example, when the CGPoint
type is used as a function argument type, its instance can be created from an array of two numbers (_x_, _y_)
or a JavaScript object with numeric x
and y
properties.
Some common iOS types from CoreGraphics
and UIKit
system frameworks are already made convertible.
Native Type | TypeScript |
---|
URL | string with a URL. When scheme is not provided, it's assumed to be a file URL. |
CGFloat | number |
CGPoint | { x: number, y: number } or number[] with x and y coords |
CGSize | { width: number, height: number } or number[] with width and height |
CGVector | { dx: number, dy: number } or number[] with dx and dy vector differentials |
CGRect | { x: number, y: number, width: number, height: number } or number[] with x, y, width and height values |
CGColor | Color hex strings in formats: #RRGGBB , #RRGGBBAA , #RGB , #RGBA |
UIColor | Color hex strings in formats: #RRGGBB , #RRGGBBAA , #RGB , #RGBA |
Record is a convertible type and an equivalent of the dictionary (Swift) or map (Kotlin), but represented as a struct where each field can have its own type and provide a default value.
struct FileReadOptions: Record {
@Field
var encoding: String = "utf8"
@Field
var position: Int = 0
@Field
var length: Int?
}
Function("readFile") { (path: String, options: FileReadOptions) -> String in
}
class FileReadOptions : Record {
@Field
val encoding: String = "utf8"
@Field
val position: Int = 0
@Field
val length: Int?
}
Function("readFile") { path: String, options: FileReadOptions ->
}
With enums we can go even further with the above example (with FileReadOptions
record) and limit supported encodings to "utf8"
and "base64"
. To use an enum as an argument or record field, it must represent a primitive value (e.g. String
, Int
) and conform to EnumArgument
.
enum FileEncoding: String, EnumArgument {
case utf8
case base64
}
struct FileReadOptions: Record {
@Field
var encoding: FileEncoding = .utf8
}
enum class FileEncoding(val value: String) {
utf8("utf8"),
base64("base64")
}
class FileReadOptions : Record {
@Field
val encoding: FileEncoding = FileEncoding.utf8
}
public class MyModule: Module {
public func definition() -> ModuleDefinition {
Name("MyFirstExpoModule")
Function("hello") { (name: String) in
return "Hello \(name)!"
}
}
}
class MyModule : Module() {
override fun definition() = ModuleDefinition {
Name("MyFirstExpoModule")
Function("hello") { name: String ->
return "Hello $name!"
}
}
}
For more examples from real modules, you can refer to Expo modules that already use this API on GitHub: