◐

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++). Make sure to set up a skeleton project:

npx create-react-native-library@latest NativeMod

Unlike the Native Modules article, we’ll use C++ to run our code directly on the JavaScript runtime. A key to this is understanding jsi.h. This is a C++ header file and it defines most everything you would want to do with the JSI.

Open jsi.h, and skim some of the contents. You’ll see familiar friends from JavaScript: Array, Object, Function, Number, and so on. With JSI, we can manipulate the JavaScript runtime however we please. 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 the console. When I invoke window.doSomethingNative(), I want it to print “Hello from your phone!”. Using JSI is pretty straightforward:

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

using namespace facebook;
using namespace std;

// This is illustrative only - you need not copy this snippet

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. But we need a TurboModule to connect our C++ code with the JavaScript runtime. Open your project’s package.json, and splice in:

{
  // ... other fields in package.json

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

Overwrite the existing codegenConfig if you see one already there. 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.

This project may come with some example files, but we don’t want these. Inspect src/, and delete the TurboModule example code (which demonstrates multiplication).

rm src/multiply.native.tsx src/multiply.tsx src/__tests__/index.test.tsx 

Create src/NativeMod.ts as below:

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

export interface Spec extends TurboModule {}

export default TurboModuleRegistry.getEnforcing<Spec>("NativeMod");

Replace src/index.tsx with the following:

export * from "./NativeMod";

We have now defined an empty TurboModule and exported it. Navigate to the example project, and ask Codegen to produce some code:

cd example/ios
bundle exec pod install

Codegen writes header and other files in ./ios/build/generated/. Feel free to explore this directory in your own time. We will now write native TurboModule code by declaring a class in ios/NativeMod.h:

#import <NativeModSpec/NativeModSpec.h>

@interface NativeMod : NSObject <NativeModSpec>

@end

Then its implementation in ios/NativeMod.mm:

#import "NativeMod.h"
#import "Foundation/Foundation.h"

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

using namespace facebook;
using namespace std;

@implementation NativeMod

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

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

- (void)installJSIBindingsWithRuntime:(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 is Objective C that can call into C++ code, which makes accessing JSI (written in C++) possible and easy.

Do you recognise anything from earlier?

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

It’s the Runtime from the JSI. We can use it to define functions and any other JavaScript object our library may need.

Installation

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

- (void)installJSIBindingsWithRuntime:(facebook::jsi::Runtime &)runtime
                          callInvoker:(const std::shared_ptr<facebook::react::CallInvoker> &)callinvoker {
    auto doSomethingNative = [](jsi::Runtime &rt, const jsi::Value &thisValue, const jsi::Value *args, size_t) -> jsi::Value {
        jsi::String helloString = jsi::String::createFromUtf8(rt, "Hello from your phone!");

        rt.global()
            .getPropertyAsObject(rt, "console")
            .getPropertyAsFunction(rt, "log")
            .call(rt, helloString);

        return jsi::Value::undefined();
    };
    auto doSomethingNativeJs = jsi::Function::createFromHostFunction(
        runtime,
        jsi::PropNameID::forAscii(runtime, "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 example/src/App.tsx and add a little call to your native function. Here’s mine:

import { useEffect } from 'react';
import { Text, View, StyleSheet } from 'react-native';

export default function App() {
  useEffect(() => {
    (global as any).doSomethingNative();
  });
  return (
    <View style={styles.container}>
      <Text>{"Hi, everybody!"}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
});

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!. That’s it! Well done. 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 will leave TurboModules in the dust.

In the next article in Part One, I show you how to do this again - but for Android.