"Simplicity is prerequisite for reliability." — Edsger W. Dijkstra, How Do We Tell Truths That Might Hurt?, 1975
Excerpt: A JSI function is a C++ lambda disguised as a JavaScript function. No serialization, no bridge, no codegen — just a C++ callable that the runtime invokes directly. This post walks you through writing one from scratch: registering it with the runtime, reading arguments, validating types, handling errors, and calling it from JavaScript. By the end, you'll have a working native module with zero boilerplate.
Series: React Native JSI Deep Dive (12 parts — series in progress) Part 1: React Native Architecture — Threads, Hermes, and the Event Loop | Part 2: React Native Bridge vs JSI — What Changed and Why | Part 3: C++ for JavaScript Developers | Part 4: Your First React Native JSI Function (You are here) | Part 5: HostObjects — Exposing C++ Classes to JavaScript | Part 6: Memory Ownership | Part 7: Platform Wiring | Part 8: Threading & Async | Part 9: Real-Time Audio in React Native — Lock-Free Pipelines with JSI | Part 10: Storage Engine | Part 11: Module Approaches | Part 12: Debugging
Quick Recap
In Part 2, we saw that JSI replaces the JSON bridge with direct C++ function calls — no serialization, no async queue. In Part 3, we learned the C++ vocabulary: references (&), pointers (*), RAII, smart pointers, lambdas with explicit captures.
Now we use all of it. This post is where you write your first line of native module code.
Installing Native Functions in the JavaScript Runtime
In a web browser, you can add JavaScript functions to the global scope (window.myFunc = ...), but you can't install native functions — functions implemented in C++ that execute without the JavaScript engine interpreting them. The browser's native API surface (fetch, setTimeout, the DOM) is fixed by the browser vendor.
In React Native, you can. JSI lets you install C++ functions directly into the JavaScript runtime. From JavaScript's perspective, they're indistinguishable from any other function. From C++'s perspective, they're lambdas that receive the runtime and arguments — executed natively, not interpreted.
The primary API for doing this is one function: jsi::Function::createFromHostFunction. (You can also create callable functions via HostObject or evaluateJavaScript, but createFromHostFunction is the dedicated, purpose-built API for registering C++ functions.)
Step 1: The Simplest Possible JSI Function
Let's start with the absolute minimum — a function that takes no arguments and returns a number:
cpp/install.cpp — the seed
#include <jsi/jsi.h>
using namespace facebook;
void install(jsi::Runtime& rt) {
auto fn = jsi::Function::createFromHostFunction(
rt, // 1. the runtime
jsi::PropNameID::forAscii(rt, "getFortyTwo"), // 2. function name (for stack traces)
0, // 3. expected argument count
[](jsi::Runtime& rt, // 4. the lambda
const jsi::Value& thisVal,
const jsi::Value* args,
size_t count) -> jsi::Value {
return jsi::Value(42);
}
);
rt.global().setProperty(rt, "getFortyTwo", std::move(fn));
}
App.js — calling it
const n = getFortyTwo();
console.log(n); // 42
output
42
Four things happen in createFromHostFunction:
-
rt— the runtime instance. Every JSI call needs this — it's the handle to the JavaScript world. -
PropNameID::forAscii(rt, "getFortyTwo")— the function's name. This shows up in error stack traces. It doesn't determine where the function is installed — that'ssetProperty's job. -
0— the expected argument count. This is informational (the JS.lengthproperty) — the runtime doesn't enforce it. -
The lambda — the actual C++ code that runs when JavaScript calls the function. It receives the runtime,
thisvalue, a pointer to the arguments array, and the argument count.
The last line — rt.global().setProperty(...) — installs the function on the JavaScript global object. After this call, any JavaScript code can call getFortyTwo().
Key Insight: The function name passed to
PropNameIDand the property name passed tosetPropertyare independent. You could name the function"internalMathOp"for stack traces but install it asglobal.getFortyTwo. In practice, keep them the same to avoid confusion.
Step 2: Reading Arguments
A function that ignores its arguments isn't very useful. Let's add two numbers:
cpp/install.cpp — reading arguments ⚠️ no validation yet
void install(jsi::Runtime& rt) {
auto add = jsi::Function::createFromHostFunction(
rt,
jsi::PropNameID::forAscii(rt, "nativeAdd"),
2, // expects 2 arguments
[](jsi::Runtime& rt,
const jsi::Value& thisVal,
const jsi::Value* args,
size_t count) -> jsi::Value {
double a = args[0].asNumber(); // ← read first argument as double
double b = args[1].asNumber(); // ← read second argument
return jsi::Value(a + b);
}
);
rt.global().setProperty(rt, "nativeAdd", std::move(add));
}
App.js
console.log(nativeAdd(3, 7)); // 10
console.log(nativeAdd(1.5, 2.5)); // 4
output
10
4
The args parameter is a pointer to an array of jsi::Value objects (as we learned in Part 3 — C-style array passing). args[0] is the first argument, args[1] is the second. The count parameter tells you how many were actually passed.
Gotcha: This code is deliberately unvalidated to keep it simple — don't ship this pattern. If JavaScript calls
nativeAdd(5)with only one argument,args[1]accesses past the end of the arguments array. That's undefined behavior in C++ — it may crash, corrupt memory, or silently produce garbage. Step 3 fixes this with propercountvalidation. Always checkcountbefore indexing intoargs.
asNumber() converts a jsi::Value to a C++ double. But what happens if JavaScript passes a string instead of a number?
Think about it: What does
nativeAdd("hello", 7)do? Theargs[0].asNumber()call encounters a string. Does it returnNaN? Does it throw? Does it crash?
It throws a C++ exception that the JSI runtime catches and converts into a JavaScript Error — catchable with try/catch on the JS side. The app doesn't crash, but the call fails with a generic error message like "expected a number." This is better than silently returning garbage, but we should validate arguments explicitly rather than relying on the conversion to throw — both for better error messages and for safety (see the Gotcha below about missing arguments).
Step 3: Validating Arguments
Production JSI functions must validate their inputs. The jsi::Value type provides type-checking methods: isNumber(), isString(), isObject(), isUndefined(), isNull(), isBool(), isSymbol(), and isBigInt().
cpp/install.cpp — with argument validation
void install(jsi::Runtime& rt) {
auto add = jsi::Function::createFromHostFunction(
rt,
jsi::PropNameID::forAscii(rt, "nativeAdd"),
2,
[](jsi::Runtime& rt,
const jsi::Value& thisVal,
const jsi::Value* args,
size_t count) -> jsi::Value {
// Validate argument count
if (count < 2) { // ← NEW
throw jsi::JSError(rt, "nativeAdd requires 2 arguments");
}
// Validate argument types
if (!args[0].isNumber() || !args[1].isNumber()) { // ← NEW
throw jsi::JSError(rt, "nativeAdd arguments must be numbers");
}
double a = args[0].asNumber();
double b = args[1].asNumber();
return jsi::Value(a + b);
}
);
rt.global().setProperty(rt, "nativeAdd", std::move(add));
}
App.js — error handling
try {
nativeAdd("hello", 7);
} catch (e) {
console.log(e.message); // "nativeAdd arguments must be numbers"
}
try {
nativeAdd(5);
} catch (e) {
console.log(e.message); // "nativeAdd requires 2 arguments"
}
output
"nativeAdd arguments must be numbers"
"nativeAdd requires 2 arguments"
The pattern is always the same:
-
Check
count— did JavaScript pass enough arguments? - Check types — are the arguments the right kind?
-
Throw
jsi::JSError— if validation fails, this becomes a catchable JavaScript error.
Gotcha: Always validate before calling
asNumber(),asString(), etc. These conversion methods throw a C++ exception on type mismatch (which the JSI runtime converts to a JS error), but the error message is generic ("Value is string, expected a number"). Your custom message —"nativeAdd arguments must be numbers"— is far more useful for debugging. More importantly, validatecountbefore indexing intoargs— accessingargs[i]wheni >= countis undefined behavior that no exception handler can catch.
Step 4: Error Handling (jsi::JSError)
jsi::JSError is the bridge between C++ exceptions and JavaScript errors. When you throw a jsi::JSError inside a host function, it propagates back to JavaScript as a regular Error object — catchable with try/catch.
The JSI runtime does catch std::exception subclasses thrown from host functions and converts them into JavaScript errors (per the jsi.h documentation: "If a C++ exception is thrown, a JS Error will be created and thrown into JS; if the C++ exception extends std::exception, the Error's message will be whatever what() returns"). However, exceptions that don't extend std::exception, or undefined behavior that doesn't throw at all (like out-of-bounds array access), will crash the app. Relying on the runtime's catch-all is fragile — the error messages are generic, and non-exception UB isn't caught.
The robust pattern: wrap your native logic in a try/catch that gives you control over error messages and catches everything:
cpp/install.cpp — safe error boundary
[](jsi::Runtime& rt,
const jsi::Value& thisVal,
const jsi::Value* args,
size_t count) -> jsi::Value {
try {
// Your native logic here
auto result = someCppFunction(args[0].asNumber());
return jsi::Value(result);
} catch (const jsi::JSError&) {
throw; // already a JS error — let it propagate
} catch (const std::exception& e) {
throw jsi::JSError(rt, std::string("Native error: ") + e.what());
} catch (...) {
throw jsi::JSError(rt, "Unknown native error");
}
}
This three-level catch ensures:
-
jsi::JSErrorpasses through unchanged (it's already a JS error). - Standard C++ exceptions (
std::runtime_error,std::invalid_argument, etc.) are wrapped with their error message. - Unknown exceptions get a generic fallback instead of crashing the app.
Key Insight: Every JSI host function is a boundary between two worlds. The JSI runtime handles
std::exceptionsubclasses automatically, but undefined behavior (dangling pointers, out-of-bounds access) bypasses all exception handling and crashes the app. Thetry/catchwrapper adds defense in depth: clearer error messages, a catch-all for non-standard exceptions, and explicit control over what JavaScript sees. Think of it as the native equivalent of a React error boundary.
The jsi::Value Type System
Before we build anything larger, let's understand the types you'll work with. jsi::Value is a tagged union — a single type that can hold any JavaScript value.
Reading Values (JS → C++)
| JavaScript Type | Type Check | Conversion | C++ Type |
|---|---|---|---|
| `number` | `val.isNumber()` | `val.asNumber()` | `double` |
| `string` | `val.isString()` | `val.asString(rt)` | `jsi::String` |
| `boolean` | `val.isBool()` | `val.asBool()` | `bool` |
| `object` | `val.isObject()` | `val.asObject(rt)` | `jsi::Object` |
| `null` | `val.isNull()` | — | — |
| `undefined` | `val.isUndefined()` | — | — |
Figure 1: jsi::Value type checks and conversions. Always check the type before converting.
Note the asymmetry: asNumber() doesn't take rt, but asString(rt) and asObject(rt) do. Numbers and booleans are plain C++ values (a double and a bool). Strings and objects are runtime-managed — they live inside the JS engine and need the runtime handle to access.
To get a std::string from a jsi::String, call .utf8(rt):
Reading a string argument
jsi::String jsStr = args[0].asString(rt); // jsi::String (engine-managed)
std::string cppStr = jsStr.utf8(rt); // std::string (C++-owned copy)
Creating Values (C++ → JS)
| C++ Value | JSI Constructor | JavaScript Result |
|---|---|---|
| `42` or `3.14` | `jsi::Value(42)` | `number` |
| `true` / `false` | `jsi::Value(true)` | `boolean` |
| `"hello"` | `jsi::String::createFromUtf8(rt, "hello")` | `string` |
| — | `jsi::Value::null()` | `null` |
| — | `jsi::Value::undefined()` | `undefined` |
| — | `jsi::Object(rt)` | `{}` (empty object) |
Figure 2: Creating JavaScript values from C++. Numbers and booleans wrap directly. Strings and objects need the runtime.
Returning different types
// Return a number
return jsi::Value(42);
// Return a string
return jsi::String::createFromUtf8(rt, "hello from C++");
// Return an object with properties
auto obj = jsi::Object(rt);
obj.setProperty(rt, "name", jsi::String::createFromUtf8(rt, "JSI"));
obj.setProperty(rt, "version", jsi::Value(4));
return obj; // JS receives: { name: "JSI", version: 4 }
Putting It Together: A Math Module
Let's build something real — a small math module with multiple functions, installed as properties on a single object rather than polluting the global scope:
cpp/MathModule.cpp — complete module
#include <jsi/jsi.h>
#include <cmath>
#include <string>
using namespace facebook;
void installMathModule(jsi::Runtime& rt) {
// Helper: validate that arg at index i is a number
auto requireNumber = [](jsi::Runtime& rt,
const jsi::Value* args,
size_t count,
size_t index,
const char* fnName) {
if (index >= count) {
throw jsi::JSError(rt,
std::string(fnName) + ": missing argument at index "
+ std::to_string(index));
}
if (!args[index].isNumber()) {
throw jsi::JSError(rt,
std::string(fnName) + ": argument " + std::to_string(index)
+ " must be a number");
}
};
// --- add(a, b) ---
auto add = jsi::Function::createFromHostFunction(
rt, jsi::PropNameID::forAscii(rt, "add"), 2,
[requireNumber](jsi::Runtime& rt, const jsi::Value&,
const jsi::Value* args, size_t count) -> jsi::Value {
requireNumber(rt, args, count, 0, "add");
requireNumber(rt, args, count, 1, "add");
return jsi::Value(args[0].asNumber() + args[1].asNumber());
}
);
// --- multiply(a, b) ---
auto multiply = jsi::Function::createFromHostFunction(
rt, jsi::PropNameID::forAscii(rt, "multiply"), 2,
[requireNumber](jsi::Runtime& rt, const jsi::Value&,
const jsi::Value* args, size_t count) -> jsi::Value {
requireNumber(rt, args, count, 0, "multiply");
requireNumber(rt, args, count, 1, "multiply");
return jsi::Value(args[0].asNumber() * args[1].asNumber());
}
);
// --- sqrt(x) ---
auto sqrt = jsi::Function::createFromHostFunction(
rt, jsi::PropNameID::forAscii(rt, "sqrt"), 1,
[requireNumber](jsi::Runtime& rt, const jsi::Value&,
const jsi::Value* args, size_t count) -> jsi::Value {
requireNumber(rt, args, count, 0, "sqrt");
double x = args[0].asNumber();
if (x < 0) {
throw jsi::JSError(rt, "sqrt: argument must be non-negative");
}
return jsi::Value(std::sqrt(x));
}
);
// --- describe() — returns an object ---
auto describe = jsi::Function::createFromHostFunction(
rt, jsi::PropNameID::forAscii(rt, "describe"), 0,
[](jsi::Runtime& rt, const jsi::Value&,
const jsi::Value* args, size_t count) -> jsi::Value {
auto obj = jsi::Object(rt);
obj.setProperty(rt, "name",
jsi::String::createFromUtf8(rt, "NativeMath"));
obj.setProperty(rt, "version", jsi::Value(1));
obj.setProperty(rt, "engine",
jsi::String::createFromUtf8(rt, "JSI"));
return obj;
}
);
// Install all functions on a single object
auto mathModule = jsi::Object(rt);
mathModule.setProperty(rt, "add", std::move(add));
mathModule.setProperty(rt, "multiply", std::move(multiply));
mathModule.setProperty(rt, "sqrt", std::move(sqrt));
mathModule.setProperty(rt, "describe", std::move(describe));
rt.global().setProperty(rt, "NativeMath", std::move(mathModule));
}
App.js — using the module
console.log(NativeMath.add(3, 7)); // 10
console.log(NativeMath.multiply(6, 7)); // 42
console.log(NativeMath.sqrt(144)); // 12
console.log(NativeMath.describe()); // { name: "NativeMath", version: 1, engine: "JSI" }
try {
NativeMath.sqrt(-1);
} catch (e) {
console.log(e.message); // "sqrt: argument must be non-negative"
}
try {
NativeMath.add("hello", 7);
} catch (e) {
console.log(e.message); // "add: argument 0 must be a number"
}
output
10
42
12
{ "name": "NativeMath", "version": 1, "engine": "JSI" }
"sqrt: argument must be non-negative"
"add: argument 0 must be a number"
Every concept from this post and Part 3 is at work:
| Pattern | What's Happening |
|---|---|
| `jsi::Runtime& rt` | Reference — borrows the runtime |
| `const jsi::Value* args` | Pointer — C-style array of arguments |
| `requireNumber` lambda | Captured by value into each host function |
| `jsi::JSError` | C++ exception → JavaScript `Error` |
| `std::move(add)` | Move semantics — transfers ownership to the module object |
| `jsi::Object(rt)` | Stack-allocated JSI object — RAII manages its handle |
| `setProperty` on `mathModule` | Installs functions on an object (not global) — cleaner namespace |
Global vs Object Installation
You have two choices for where to install your functions:
Global installation — the function is available everywhere:
Global — available as a bare function
rt.global().setProperty(rt, "nativeAdd", std::move(fn));
// JS: nativeAdd(3, 7)
Object installation — the function is namespaced:
Object — namespaced under a module
auto module = jsi::Object(rt);
module.setProperty(rt, "add", std::move(fn));
rt.global().setProperty(rt, "NativeMath", std::move(module));
// JS: NativeMath.add(3, 7)
Prefer object installation. It avoids polluting the global namespace, groups related functions together, and matches how JavaScript modules work. The only reason to use global installation is for very simple, single-function modules.
The Tradeoffs (What This Approach Can't Do)
Pure JSI functions — as shown in this post — are powerful but limited:
| Capability | JSI Host Functions | What You Need Instead |
|---|---|---|
| Synchronous calls | Yes — runs on JS thread | — |
| Return values | Yes — any `jsi::Value` | — |
| Stateful modules | Possible via `shared_ptr` captures, but verbose — no properties, no `this` | **HostObjects** (Part 5) — expose C++ classes with a clean interface |
| Async operations | No — must return synchronously | **CallInvoker** (Part 8) — background threads + Promises |
| Platform APIs | No — pure C++ only | **Platform wiring** (Part 7) — Obj-C++/JNI bridges |
| Type safety from JS | No — manual validation | **TurboModules** (Part 11) — codegen from Flow/TS specs |
Figure 3: What JSI host functions can and can't do. Parts 5–11 address each limitation.
The biggest ergonomic limitation: no clean stateful interface. You can capture shared_ptr in lambdas to share state (as we did in Part 3's key-value store example), but it gets verbose fast — no property access, no this, and no way to group methods on an object that JavaScript can inspect. When you want a database connection, a cache, or a streaming audio session, you need a C++ object that JavaScript can interact with as a first-class object. That's what HostObjects provide — and that's Part 5.
Key Takeaways
-
createFromHostFunctionis the core API. It takes a runtime, a name (for stack traces), an argument count, and a C++ lambda. The lambda is what JavaScript calls. That's the entire mechanism. -
Always validate arguments. Check
countbefore accessingargs[index]. CheckisNumber()/isString()/isObject()before callingasNumber()/asString(rt)/asObject(rt). Never trust that JavaScript passed what you expect. -
Wrap native errors in
jsi::JSError. The JSI runtime catchesstd::exceptionsubclasses automatically, but the error messages are generic. Wrapping intry/catchgives you clear error messages and catches non-standard exceptions. Letjsi::JSErrorpass through unchanged. Undefined behavior (out-of-bounds access, dangling pointers) bypasses all exception handling — validate inputs first. -
Install on objects, not globals. Group related functions under a namespace object (
NativeMath.add) rather than polluting the global scope (nativeAdd). It's cleaner and matches JavaScript conventions. -
Host functions are synchronous. They execute on the JS thread and return immediately. If your operation takes more than ~1ms, you'll block the event loop. Async patterns (background threads + Promises) come in Part 8.
Frequently Asked Questions
How do you create a JSI function in React Native?
Use jsi::Function::createFromHostFunction() to register a C++ lambda as a JavaScript function. The lambda receives the runtime, arguments as jsi::Value, and returns a jsi::Value.
Can JSI functions be synchronous?
Yes — JSI functions execute synchronously on the JS thread, returning results immediately without Promises or callbacks. This is only safe for operations completing in under ~1ms.
What happens if a JSI function throws?
C++ exceptions extending std::exception are caught by the JSI runtime and converted into JavaScript Error objects, catchable with try/catch on the JS side.
What's Next
You can now install C++ functions into JavaScript. But they're stateless — each call is independent. What if you want to expose a C++ object to JavaScript? A database connection that remembers its state. A cache you can read and write. An audio session you can start, pause, and stop.
In Part 5: HostObjects — Exposing C++ Classes to JavaScript, you'll learn to expose C++ classes to JavaScript as first-class objects with properties and methods. HostObjects are where JSI stops being a curiosity and becomes a real native module framework. You'll build a key-value store where storage.get('key') calls C++ synchronously — no await, no bridge, no serialization.
Part 4 gave you functions. Part 5 gives you objects.
References & Further Reading
- JSI Header — jsi.h (Complete API Surface, facebook/react-native)
- React Native — The New Architecture (Official Documentation)
- react-native-mmkv — Production JSI Module (Source Code Reference)
- react-native-vision-camera — Production JSI + HostObject Module (Source Code Reference)
- cppreference — Lambda Expressions
- cppreference — std::exception
Top comments (0)