Writing React Native Modules With JSI - Part One (iOS)

About a year ago, I begged my boss to move me into the mobile development team. At work, we produce and maintain several React Native apps. Having been bored of backend development, I felt it was time to upskill. In the time since, I have learnt a lot.

One of my goals was to learn how React Native works under the hood. I always wondered how a JavaScript program interacts with the system it runs on: reading and writing files, network requests, input and output, all of that. I didn’t want a hand-wave explanation either - I wanted to know just how React Native makes a JavaScript program capable of doing everything a native application can do.

In this article, I’ll share some basic knowledge of what I know. I’ll show how to make a native module for iOS. React Native provides TurboModules to do this. But we’re going to go a bit deeper. We’ll make a TurboModule, but write our code directly with the JavaScript Interface (JSI).

As a prerequisite, I recommend reading React Native’s Native Modules introduction. Then move to Cross-Platform Native Modules (C++).

Unlike the introduction article, we’ll use C++ to set up our program directly on the JavaScript runtime. A key to this is understanding jsi.h. This is a C++ header file which allows you to do anything you might want to do on your app’s runtime.

Open jsi.h, and take a look at the file. You’ll see familiar friends: Array, Object, Function, Number, and so on. You can manipulate the JavaScript runtime however you please. Your imagination is the only limit.

Defining a native function

Let’s get started with a simple goal: create a Hello World function that prints a message to STDOUT. When I invoke window.doSomethingNative(), I want it to print “Hello from your phone!”. This is straightforward:

#include <iostream>
#include <jsi/jsi.h>

using namespace facebook;
using namespace std;

auto doSomethingNative = [](jsi::Runtime &rt, const jsi::Value &thisValue, const jsi::Value *args, size_t) -> jsi::Value {
    cout << "Hello from your phone!\n";
    return jsi::Value::undefined();
};
auto doSomethingNativeJs = jsi::Function::createFromHostFunction(
    rt, 
    jsi::PropNameID::forAscii(rt, "doSomethingNative"),
    0,
    doSomethingNative
);

The doSomethingNativeJs variable now holds a function that can be attached to the JavaScript runtime! The parameters of createFromHostFunction accept the current runtime (rt), function name (name), parameter count (paramCount), and the implementation (func). In the code above, func is a C++ closure.

static Function createFromHostFunction(
      IRuntime& runtime,
      const jsi::PropNameID& name,
      unsigned int paramCount,
      jsi::HostFunctionType func
  );

Connect runtime with TurboModule

This is not a TurboModule tutorial. We need a TurboModule to connect our C++ code with the JavaScript runtime. Open your project’s package.json, and add:

{
  // ... name, dependencies, etc.
  "codegenConfig": {
    "name": "NativeModSpec",
    "type": "modules",
    "jsSrcsDir": "src",
    "android": {
      "javaPackageName": "com.nativemod"
    }
  }
}

This instructs React Native to search src for any spec files. Codegen will generate Objective-C and Java code from each spec. This tutorial is for iOS, so we’ll be looking at some Objective C code shortly. Create src/native.spec.ts as below:

import { TurboModuleRegistry, type TurboModule } from 'react-native';

export interface Spec extends TurboModule {}

export default TurboModuleRegistry.getEnforcing<Spec>('NativeModSpec');

It’s empty! This is good - ask Codegen to make some code now:

cd example/ios
pod install

Note that you changed into the example project’s iOS directory. Look out for this line:

TODO: The relevant line goes here.

Codegen defines the header file in NativeModSpec.h, and we define the implementation in ios/NativeModSpec.mm. Here’s a minimal example which will compile.

#import "NativeModSpec.h"
#import "Foundation/Foundation.h"

#import <jsi/jsi.h>;
#include <utility>

using namespace facebook;
using namespace std;

@implementation NativeModSpec

+ (NSString *)moduleName
{
  return @"NativeMod";
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
    (const facebook::react::ObjCTurboModule::InitParams &)params
{
    return std::make_shared<facebook::react::NativeNativeModSpecJSI>(params);
}

- (void)installJSIBindingsWithRuntime:(facebook::jsi::Runtime&)runtime {
    // TODO
}

@end

If you are not at all familiar with Objective-C, its syntax will look very strange. And given that this is Objective-C++ (.mm), it will look even stranger. Objective-C++ is very useful though. It can call into C++ code - which makes accessing JSI (written in C++) possible and easy.

Do you recognise anything from earlier?

- (void)installJSIBindingsWithRuntime:(facebook::jsi::Runtime&)runtime;

It’s the Runtime from the JSI. We can use it to define functions and anything else we want. After installJSIBindingsWithRuntime is complete, whatever you set up will be available for your React Native app to use.

Installation

We defined a Hello World function earlier. Let’s install it onto the runtime.

- (void)installJSIBindingsWithRuntime:(facebook::jsi::Runtime&)runtime {
    auto doSomethingNative = [](jsi::Runtime &rt, const jsi::Value &thisValue, const jsi::Value *args, size_t) -> jsi::Value {
        cout << "Hello from your phone!\n";
        return jsi::Value::undefined();
    };
    auto doSomethingNativeJs = Function::createFromHostFunction(
        rt, 
        PropNameID::forAscii(rt, "doSomethingNative"),
        0,
        doSomethingNative
    );
    runtime.global().setProperty(runtime, "doSomethingNative", std::move(doSomethingNativeJs));
}

The last line in installJSIBindingsWithRuntime connects everything together. Note the use of std::move. C++ makes use of move semantics. setProperty moves doSomethingNativeJs to the runtime, which will own it from now on. Since the runtime manages the resource henceforth, std::move ensures it is transferred rather than copied.

Open App.tsx and add a little call to your native function:

(global as any).doSomethingNative();

ESLint may complain about the as any. Start your app in an iOS Simulator or on a device:

yarn ios

Press J to inspect output using Chrome DevTools. You should see Hello from your phone!. Congratulations on writing this native module. Not only did you write a native module for iOS, you did it directly with JSI. And though it was just one function, once you get familiar with the JSI, you can do many more things that would leave TurboModules in the dust.

Asynchronous programming is really important in native modules. All JavaScript runtimes are designed to work with non-blocking IO. In the upcoming article, we’ll use Promises to make an asynchronous native module.