Skip to main content
Back to Blog
React NativeUnity3DJSIFabricMobile DevelopmentBridgeless Architecture

How to Embed a 3D Unity Engine in React Native Using JSI and the 2026 Bridgeless Architecture

A deep technical walkthrough on integrating a compiled Unity 3D engine into React Native apps using JSI, Fabric, and the fully bridgeless architecture for synchronous, zero-latency communication.

September 4, 202616 min readNiraj Kumar

Introduction

If you tried embedding a Unity 3D scene inside a React Native app back in 2022, you probably remember the pain: a NativeModules bridge, a mountain of JSON.stringify calls, and a noticeable stutter every time you tried to rotate a 3D model with your finger. That entire mental model is now obsolete.

As of 2026, the legacy React Native bridge — the asynchronous, batched, JSON-serializing message queue that powered the framework for nearly a decade — has been fully retired. Every app built on a current React Native release now runs on the bridgeless architecture, where JSI (JavaScript Interface) and Fabric are not optional performance upgrades but the only way the framework works.

This shift matters enormously for anyone trying to embed a heavyweight native engine like Unity inside a React Native app. Unity isn't a simple native UI component — it's a full C/C++ and C# runtime with its own render loop, physics engine, and memory management. Historically, getting Unity's state (camera position, animation triggers, physics events) in and out of React Native's JavaScript thread meant serializing data into JSON, pushing it across the bridge, and waiting for a round trip. For a 3D configurator running at 60fps, that latency was often the difference between a delightful interaction and a nauseating one.

In this guide, we're going to build a production-grade integration pattern for embedding a compiled Unity engine into a React Native app using:

  • Unity as a Library (UaaL) to embed the Unity player as a native surface
  • Fabric to manage that native view inside React Native's new render tree
  • JSI HostObjects to expose synchronous, zero-copy communication between JavaScript and the native Unity runtime

By the end, you'll understand not just the "how," but the architectural reasoning behind why this is now the correct way to do cross-engine embedding on mobile.


Why the Legacy Bridge Had to Die

Before diving into code, it's worth understanding what actually changed and why it matters for a Unity integration specifically.

The old bridge worked like this: JavaScript calls were serialized into a JSON message queue, batched, sent across an asynchronous channel, deserialized on the native side, executed, and then the result (if any) traveled back through the same round trip. This worked fine for things like "fetch this data" or "log this analytics event." It fell apart completely for:

  • High-frequency updates — like syncing a joystick's X/Y position to a Unity camera 60 times a second
  • Large payloads — like passing vertex data, texture buffers, or transform matrices
  • Synchronous reads — like asking "what's the current animation frame?" and needing the answer immediately, not on the next event loop tick

Every one of these is a core requirement for a real-time 3D engine integration. With JSI, JavaScript objects and native (C++) objects live in the same runtime memory space. There's no serialization step. A HostObject exposed via JSI behaves like a real JavaScript object with real methods — except those methods are backed by native C++ functions that can synchronously call into Unity's embedded runtime.

Fabric complements this by changing how React Native manages the native view tree. Instead of asynchronous "shadow tree" reconciliation traveling through the bridge, Fabric uses C++ core across all platforms, meaning the Unity UIView/SurfaceView you embed is managed with the same synchronous, thread-aware precision as any other native component.


Architecture Overview

Before writing any code, let's map out the moving pieces. A bridgeless Unity integration has four layers:

  1. Unity Player (C#/C++/IL2CPP) — compiled as a library and embedded as a native view (UnityFramework on iOS, .aar on Android)
  2. Native Glue Layer (Objective-C++ / Kotlin+JNI) — wraps the Unity view lifecycle and exposes native methods to C++
  3. JSI HostObject (C++) — the synchronous bridge layer exposed directly into the JS runtime (Hermes)
  4. Fabric Native Component — the React Native component definition that mounts the Unity view inside the UI tree
┌─────────────────────────────┐
│   React Native (JS/Hermes)  │
│   <UnityView /> + JSI calls │
└──────────────┬───────────────┘
               │ JSI (synchronous, in-process)
┌──────────────▼───────────────┐
│     C++ HostObject Bridge    │
└──────────────┬───────────────┘
               │ Native calls (no serialization)
┌──────────────▼───────────────┐
│  Native Glue (ObjC++ / JNI)  │
└──────────────┬───────────────┘
               │
┌──────────────▼───────────────┐
│   Unity Player (IL2CPP/C#)   │
│   UnityFramework / .aar      │
└───────────────────────────────┘

The key architectural insight: JSI doesn't talk to Unity directly. It talks to a thin C++ shim that talks to Unity's native embedding APIs. This separation keeps your JSI layer engine-agnostic and testable, while all the Unity-specific lifecycle quirks live in the glue layer where they belong.


Step 1: Exporting Unity as a Library

Unity has supported "Unity as a Library" (UaaL) since 2019, and by 2026 it's a mature, first-class export target. From the Unity Editor:

  1. Go to File → Build Settings
  2. Select your target platform (iOS or Android)
  3. Enable "Export Project" instead of building a full app
  4. Export to a folder like unity-export/

This gives you:

  • iOS: An Xcode project containing UnityFramework.framework, which you embed into your React Native iOS project
  • Android: A Gradle module producing unityLibrary (an .aar), which you add as a dependency to your React Native Android project

The critical detail most tutorials skip: Unity's UnityFramework (iOS) and UnityPlayer (Android) both expose a single active instance model. You cannot spin up multiple Unity runtimes inside one app process. Your native glue layer must be a singleton that manages exactly one UnityFramework lifecycle, because if React Native tries to remount the component (which Fabric can do during fast refresh or navigation transitions), you need to pause and resume the existing Unity instance rather than destroying and recreating it. Recreating IL2CPP runtime state is expensive and can cause visible frame drops or crashes.


Step 2: The Native Glue Layer

iOS (Objective-C++)

Your glue class wraps UnityFramework and exposes a clean native API surface. This is what your JSI layer will call into.

// UnityBridgeManager.mm

#import "UnityBridgeManager.h"
#import <UnityFramework/UnityFramework.h>

@interface UnityBridgeManager () <UnityFrameworkListener>
@property (nonatomic, strong) UnityFramework *ufw;
@end

@implementation UnityBridgeManager

+ (instancetype)shared {
    static UnityBridgeManager *instance = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        instance = [[UnityBridgeManager alloc] init];
    });
    return instance;
}

- (UnityFramework *)initUnity {
    if (self.ufw) {
        return self.ufw; // Reuse existing instance — never re-init
    }

    NSString *bundlePath = [[NSBundle mainBundle] bundlePath];
    bundlePath = [bundlePath stringByAppendingString:@"/Frameworks/UnityFramework.framework"];

    NSBundle *bundle = [NSBundle bundleWithPath:bundlePath];
    if ([bundle isLoaded] == false) [bundle load];

    UnityFramework *ufw = [bundle.principalClass getInstance];
    [ufw setExecuteHeader:&_mh_execute_header];
    [ufw setDataBundleId:"com.yourcompany.unityexport"];
    [ufw registerFrameworkListener:self];
    [ufw runEmbeddedWithArgc:0 argv:NULL appLaunchOpts:nil];

    self.ufw = ufw;
    return ufw;
}

// Synchronous setter — called directly from the JSI layer
- (void)sendRotation:(float)x y:(float)y z:(float)z {
    NSString *message = [NSString stringWithFormat:@"%f|%f|%f", x, y, z];
    [self.ufw sendMessageToGOWithName:"SceneController"
                            functionName:"SetRotationFromNative"
                                 message:message.UTF8String];
}

// Synchronous getter — reads a cached value updated by Unity's render loop
- (float)getCurrentFPS {
    return self.ufw != nil ? [self.ufw getCurrentFPS] : 0.0f;
}

@end

Notice the sendRotation method is still using Unity's SendMessage API under the hood — that part hasn't changed since Unity doesn't expose a truly synchronous C# call path from native code without custom native plugins. The synchronicity we gain is on the React Native → C++ → Objective-C++ leg. On the C# side, we minimize latency by keeping SceneController.SetRotationFromNative a lightweight, allocation-free method that just writes to a Vector3 field consumed on the next Update() tick.

Android (Kotlin + JNI)

// UnityBridgeManager.kt

object UnityBridgeManager {
    private var unityPlayer: UnityPlayer? = null

    fun initUnity(context: Context): UnityPlayer {
        return unityPlayer ?: UnityPlayer(context).also {
            unityPlayer = it
        }
    }

    fun sendRotation(x: Float, y: Float, z: Float) {
        UnityPlayer.UnitySendMessage(
            "SceneController",
            "SetRotationFromNative",
            "$x|$y|$z"
        )
    }

    fun getCurrentFPS(): Float {
        return unityPlayer?.let { nativeGetFPS() } ?: 0f
    }

    private external fun nativeGetFPS(): Float
}

Step 3: Building the JSI HostObject

This is where the real architectural shift happens. Instead of registering a NativeModule that goes through the old bridge, we install a HostObject directly into the JS runtime at app startup.

// UnityHostObject.h

#pragma once
#include <jsi/jsi.h>

using namespace facebook;

class UnityHostObject : public jsi::HostObject {
public:
  jsi::Value get(jsi::Runtime& rt, const jsi::PropNameID& name) override;
  void set(jsi::Runtime& rt, const jsi::PropNameID& name, const jsi::Value& value) override;
  std::vector<jsi::PropNameID> getPropertyNames(jsi::Runtime& rt) override;
};
// UnityHostObject.cpp

#include "UnityHostObject.h"
#include "UnityBridgeShim.h" // platform-specific C shim

jsi::Value UnityHostObject::get(jsi::Runtime& rt, const jsi::PropNameID& name) {
  auto propName = name.utf8(rt);

  if (propName == "setRotation") {
    return jsi::Function::createFromHostFunction(
      rt,
      name,
      3, // arg count: x, y, z
      [](jsi::Runtime& rt, const jsi::Value&, const jsi::Value* args, size_t count) -> jsi::Value {
        if (count < 3) {
          throw jsi::JSError(rt, "setRotation requires x, y, z arguments");
        }
        float x = static_cast<float>(args[0].asNumber());
        float y = static_cast<float>(args[1].asNumber());
        float z = static_cast<float>(args[2].asNumber());

        // Synchronous native call — no queue, no JSON, no round trip
        UnityShim_SendRotation(x, y, z);

        return jsi::Value::undefined();
      }
    );
  }

  if (propName == "getCurrentFPS") {
    return jsi::Function::createFromHostFunction(
      rt,
      name,
      0,
      [](jsi::Runtime& rt, const jsi::Value&, const jsi::Value*, size_t) -> jsi::Value {
        float fps = UnityShim_GetCurrentFPS();
        return jsi::Value(static_cast<double>(fps));
      }
    );
  }

  return jsi::Value::undefined();
}

void UnityHostObject::set(jsi::Runtime&, const jsi::PropNameID&, const jsi::Value&) {
  // Read-only surface — writes go through explicit methods, not property assignment
}

std::vector<jsi::PropNameID> UnityHostObject::getPropertyNames(jsi::Runtime& rt) {
  return jsi::PropNameID::names(rt, "setRotation", "getCurrentFPS");
}

Now install this HostObject into the runtime during app startup, typically inside your ReactInstanceManager initialization or AppDelegate/MainApplication bootstrap:

// Installed once when the JS runtime is created
void installUnityBridge(jsi::Runtime& runtime) {
  auto hostObject = std::make_shared<UnityHostObject>();
  auto jsObject = jsi::Object::createFromHostObject(runtime, hostObject);
  runtime.global().setProperty(runtime, "__UnityBridge", jsObject);
}

From this point on, global.__UnityBridge.setRotation(x, y, z) executes as a direct, synchronous C++ function call from JavaScript — no serialization, no message queue, no waiting for the next batch flush.


Step 4: The Fabric Native Component

Exposing the Unity view itself (not just the data channel) requires a Fabric Native Component. This is what lets <UnityView /> render inside your React tree like any other native element, with proper layout, gesture handling, and lifecycle management.

// UnityViewNativeComponent.ts
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import type { ViewProps } from 'react-native';
import type { Int32 } from 'react-native/Libraries/Types/CodegenTypes';

export interface NativeProps extends ViewProps {
  sceneName?: string;
  renderMode?: Int32;
}

export default codegenNativeComponent<NativeProps>('UnityView');

Codegen reads this spec at build time and generates the platform-specific scaffolding (UnityViewComponentDescriptor, ShadowNode, and view manager glue) automatically — a huge improvement over the manually-written ViewManager boilerplate required pre-Fabric.

On the React side, you compose the component and the JSI bridge together:

// UnityScene.tsx
import React, { useCallback, useRef } from 'react';
import { View, PanResponder, StyleSheet } from 'react-native';
import UnityView from './UnityViewNativeComponent';

// Access the synchronous JSI bridge installed at startup
const UnityBridge = (global as any).__UnityBridge;

export default function UnityScene() {
  const lastRotation = useRef({ x: 0, y: 0 });

  const panResponder = useRef(
    PanResponder.create({
      onStartShouldSetPanResponder: () => true,
      onPanResponderMove: (_, gesture) => {
        const x = lastRotation.current.x + gesture.dx * 0.01;
        const y = lastRotation.current.y + gesture.dy * 0.01;

        // Synchronous call — updates Unity's camera on the same JS tick,
        // no bridge latency, no dropped frames on fast swipes
        UnityBridge.setRotation(x, y, 0);
      },
      onPanResponderRelease: (_, gesture) => {
        lastRotation.current.x += gesture.dx * 0.01;
        lastRotation.current.y += gesture.dy * 0.01;
      },
    })
  ).current;

  return (
    <View style={styles.container} {...panResponder.panHandlers}>
      <UnityView style={styles.unity} sceneName="ProductConfigurator" />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  unity: { flex: 1 },
});

Real-World Example: An Interactive Product Configurator

The pattern above shines brightest in commerce and industrial use cases where a 3D model needs to respond instantly to user input layered on top of a normal React Native UI — think sneaker customizers, furniture configurators, or automotive trim selectors.

A typical production flow looks like this:

  • React Native renders the surrounding UI: color swatches, material pickers, price display, "Add to Cart" button
  • The <UnityView /> fills the canvas area, rendering the actual 3D product
  • Swatch taps call UnityBridge.setMaterial(materialId) synchronously — the model updates within the same frame
  • Unity reports back interaction events (e.g., "user tapped the left shoe panel") through a callback registered on the HostObject, letting React Native update a side panel without any polling

Here's how you'd register a native-to-JS callback using JSI's Function type, which is one of the more powerful (and easy to misuse) capabilities in this architecture:

// Registering a JS callback from native code
jsi::Value UnityHostObject::get(jsi::Runtime& rt, const jsi::PropNameID& name) {
  auto propName = name.utf8(rt);

  if (propName == "onPartSelected") {
    return jsi::Function::createFromHostFunction(
      rt, name, 1,
      [this](jsi::Runtime& rt, const jsi::Value&, const jsi::Value* args, size_t) -> jsi::Value {
        // Store the JS callback as a shared_ptr so it survives past this call
        callback_ = std::make_shared<jsi::Function>(args[0].asObject(rt).asFunction(rt));
        return jsi::Value::undefined();
      }
    );
  }
  // ... other properties
  return jsi::Value::undefined();
}

// Called from the Unity glue layer when a part is tapped in 3D space
void UnityHostObject::notifyPartSelected(jsi::Runtime& rt, const std::string& partId) {
  if (callback_) {
    callback_->call(rt, jsi::String::createFromUtf8(rt, partId));
  }
}

This callback must be invoked on the JS thread, so your native glue layer needs a reference to the JS CallInvoker (available via the React Native runtime) to safely schedule the call rather than invoking it directly from Unity's render thread.


Best Practices

  • Treat the Unity instance as a singleton. Never destroy and recreate UnityFramework/UnityPlayer on component remount — pause and resume instead.
  • Keep HostObject methods small and allocation-free. JSI calls are synchronous, so any blocking work inside them stalls the JS thread directly.
  • Use the CallInvoker for any Unity-to-JS callback. Never call back into the JS runtime from Unity's native render thread — you'll get intermittent crashes that are painful to reproduce.
  • Batch high-frequency updates when precision doesn't matter. For things like continuous rotation, throttle to your display's refresh rate (e.g., via requestAnimationFrame) rather than firing on every raw touch event.
  • Profile with Perfetto (Android) and Instruments (iOS). Bridgeless doesn't mean bottleneck-free — Unity's own render thread and IL2CPP GC pauses are now your primary suspects for jank.
  • Version-lock your Unity export. Unity's native plugin ABI can shift between LTS releases; pin your Unity Editor version to match what your native glue layer was compiled against.
  • Guard against nil/null Unity instances. Always check that UnityFramework/UnityPlayer is initialized before routing a JSI call into it, especially during cold start races.

Common Mistakes

  • Assuming JSI makes everything synchronous end-to-end. JSI makes the JS-to-C++ leg synchronous. The C++-to-Unity leg (via SendMessage) is still effectively asynchronous internally — manage expectations accordingly.
  • Recreating the Unity view on every navigation change. This is the single most common cause of memory leaks and jarring reloads in Unity/React Native hybrids.
  • Forgetting to unregister the Fabric component on unmount. Leaving a dangling native surface attached can cause both React Native and Unity to fight over touch event ownership.
  • Calling JSI HostObject methods before the runtime is ready. If your JS bundle executes before native module installation completes, global.__UnityBridge will be undefined — always guard with a readiness check.
  • Ignoring thread affinity. Unity's render loop, React Native's JS thread, and the native UI thread are three separate execution contexts. Mixing them up is the number one source of hard-to-debug crashes in this architecture.
  • Over-fetching state from Unity every frame. Just because a synchronous getter is fast doesn't mean it's free — polling getCurrentFPS() in a tight render loop still adds overhead.

🚀 Pro Tips

  • Use jsi::Object::isHostObject checks in debug builds to catch cases where your bridge object gets accidentally overwritten by JS code.
  • Expose a isUnityReady() synchronous getter on your HostObject so React components can gate rendering (e.g., show a loading skeleton) without relying on brittle timers.
  • Wrap your JSI bridge in a small TypeScript adapter module (rather than accessing global.__UnityBridge directly everywhere) so you get autocomplete, type safety, and a single point to mock in tests.
  • Consider a shared memory buffer (via ArrayBuffer) for high-frequency numeric data, like streaming transform matrices, instead of individual function calls — this can shave off even more overhead for particle systems or skeletal animation data.
  • Use Fabric's ShadowNode layout events to tell Unity exactly how much screen space it has, instead of hardcoding dimensions — this avoids ugly black bars when the surrounding React UI resizes dynamically (e.g., keyboard appearing).
  • Set up a debug overlay in your Unity scene that renders JSI call counts and timings directly in 3D space during development — it's far easier to spot excessive call frequency visually than by reading logs.

📌 Key Takeaways

  • The React Native bridge is gone for good in 2026 — JSI and Fabric are the mandatory foundation for any serious native integration, including Unity.
  • Embedding Unity requires two distinct integration layers: a native glue layer (Objective-C++/Kotlin) that manages the Unity lifecycle, and a JSI HostObject layer that exposes synchronous methods to JavaScript.
  • Fabric Native Components (via Codegen) replace the old ViewManager pattern for mounting the actual Unity render surface inside the React tree.
  • True end-to-end synchronicity has limits — Unity's internal SendMessage mechanism and thread boundaries mean you still need careful callback and threading discipline, even with a bridgeless architecture.
  • Treat the Unity player as a long-lived singleton, minimize per-frame allocations in your HostObject methods, and always route Unity-to-JS callbacks through the proper CallInvoker.

Conclusion

Embedding a full 3D engine inside a React Native app used to be an exercise in patience — chasing frame drops caused by JSON serialization, wrestling with stale bridge modules, and hoping your NativeEventEmitter didn't silently drop messages under load. The 2026 bridgeless architecture doesn't just make this faster; it fundamentally changes the integration model. JSI turns your native Unity bridge into something that feels like a real JavaScript object, and Fabric turns the Unity render surface into a real citizen of your component tree.

The result is a mobile app architecture capable of genuinely fluid, real-time 3D interaction — configurators, AR-adjacent product viewers, even light game-like experiences — built on top of the same React Native codebase powering your buttons and forms. It takes more native code than the average React Native feature, and the threading model demands respect, but the payoff is an integration that finally feels native, because architecturally, it finally is.

If you're building your first Unity-in-React-Native integration this year, start small: get a static model rendering through Fabric, wire up a single synchronous JSI call, and only then layer in callbacks and high-frequency updates. The architecture rewards patience during setup with a genuinely delightful runtime experience.


References

Discussion

All Articles
React NativeUnity3DJSIFabricMobile DevelopmentBridgeless Architecture

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.