How to Use Rust with React Native via FFI

You must compile Rust code into a C++ library to use it with React Native, as direct FFI is not supported by the JavaScript engine.

When JavaScript hits the wall

You are building a React Native app. You have a heavy image filter or a crypto hash function that runs too slow in JavaScript. You find a perfect Rust crate, sha3 or image, and you want to drop it in. You try to import the Rust code directly from your JavaScript file. The bundler throws a tantrum. Hermes doesn't speak Rust. JavaScript engines don't load .rs files. You need a bridge.

Rust compiles to machine code. React Native runs JavaScript. They live in different worlds. To connect them, you use FFI. FFI stands for Foreign Function Interface. It is the handshake protocol between languages. Rust can export functions that look like C functions. C is the universal translator of systems programming. React Native's native modules speak C++. C++ talks to C just fine. So the chain is: Rust exports C-style functions, C++ calls them, React Native calls C++.

Think of React Native as a restaurant. JavaScript is the dining room. The kitchen is the native platform. Rust is a specialized spice vendor who only speaks a dialect of C. The chef (C++) takes the order from the dining room, goes to the spice vendor, gets the spice using the C dialect, and brings it back to cook the dish. You cannot send a diner to the spice vendor. You need the chef to translate.

The bridge architecture

The connection happens in layers. Your JavaScript code calls a native module. That native module is written in C++ and registered with React Native. The C++ code calls a function exported by your Rust library. The Rust function runs, does the work, and returns a result. The result travels back up the chain.

Rust must export functions using the C calling convention. The calling convention defines how arguments are passed, how the stack is managed, and how the return value is delivered. If Rust uses its own convention and C++ expects C, the registers will hold garbage. The app crashes. You fix this with extern "C".

Rust also mangles function names by default. Mangling adds hashes and metadata to names to support overloading and modules. C does not understand mangled names. If you export rust_add, the linker might see _ZN12my_crate8rust_add17h3f4a5b6c7d8e9f0aE. C++ looks for rust_add and fails. You fix this with #[no_mangle].

Convention aside: The community always pairs #[no_mangle] with extern "C" for FFI exports. If you see one, expect the other. It is the standard signal that this function crosses a language boundary.

Minimal example

Here is the smallest working Rust library for FFI. It exports a single function that adds two integers.

// lib.rs
/// Add two integers and return the result.
///
/// This function is safe to call from C or C++.
#[no_mangle]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
    a + b
}

The #[no_mangle] attribute tells the compiler to keep the symbol name exactly as written. The extern "C" attribute switches the calling convention to C. The function signature uses i32. On almost all platforms, i32 matches the C int size. You can call this from C++ with extern "C" int rust_add(int a, int b);.

Compile this with cargo build --release. You get a static library file, usually libmy_crate.a on Linux and macOS, or my_crate.lib on Windows. Copy that file to your React Native project. Link it in your CMakeLists.txt or Android.mk. Write a C++ wrapper that calls rust_add. Expose the wrapper to JavaScript. You are done.

Convention aside: Use std::os::raw::c_int instead of i32 in FFI signatures when you want to be explicit. On most platforms they are identical, but c_int documents that this value crosses a language boundary. It helps readers audit the code later.

Memory ownership rules

FFI breaks Rust's ownership guarantees. Rust cannot track memory allocated by C++. C++ cannot track memory allocated by Rust. If you pass a pointer across the boundary, you must agree on who owns it.

The golden rule is simple: the allocator owns the deallocator. If Rust allocates memory, Rust must free it. If C++ allocates memory, C++ must free it. Never mix allocators.

If Rust returns a pointer to a heap allocation, you must also export a function to free it. C++ cannot call free on Rust memory. The allocators might use different strategies. Calling the wrong free causes heap corruption.

use std::alloc::{alloc, Layout};
use std::ptr;

/// Allocate a buffer of zeros.
///
/// # Safety
/// Caller must eventually call `rust_free_buffer` with the returned pointer.
#[no_mangle]
pub unsafe extern "C" fn rust_alloc_buffer(size: usize) -> *mut u8 {
    // SAFETY: Layout::from_size_align panics on invalid inputs.
    // Caller must ensure size is reasonable.
    let layout = Layout::from_size_align(size, 8).unwrap_unchecked();
    let ptr = alloc(layout);
    if ptr.is_null() {
        std::alloc::handle_alloc_error(layout);
    }
    ptr::write_bytes(ptr, 0, size);
    ptr
}

/// Free a buffer allocated by `rust_alloc_buffer`.
///
/// # Safety
/// `ptr` must have been returned by `rust_alloc_buffer` and not yet freed.
#[no_mangle]
pub unsafe extern "C" fn rust_free_buffer(ptr: *mut u8, size: usize) {
    // SAFETY: Caller guarantees ptr was allocated by rust_alloc_buffer.
    let layout = Layout::from_size_align(size, 8).unwrap_unchecked();
    std::alloc::dealloc(ptr, layout);
}

This pattern is verbose. It forces the caller to remember to free the memory. Forgetting to free leaks memory. Freeing twice crashes the app. Treat the FFI boundary as a firewall. Nothing crosses without inspection.

Panic handling

Rust panics abort the process by default. In a standalone Rust binary, this is fine. The program exits. In React Native, a panic kills the entire app. The JavaScript engine dies. The UI vanishes. Users see a crash.

You must catch panics before they escape the FFI boundary. Use std::panic::catch_unwind. Wrap every FFI function in a safe layer that catches panics and returns an error code.

use std::panic::catch_unwind;

/// Internal implementation. Can panic.
fn rust_add_impl(a: i32, b: i32) -> i32 {
    if a < 0 || b < 0 {
        panic!("Negative inputs not supported");
    }
    a + b
}

/// Safe FFI entry point.
///
/// Returns -1 on panic or error.
#[no_mangle]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
    catch_unwind(|| rust_add_impl(a, b))
        .unwrap_or(-1)
}

The catch_unwind function runs the closure. If it panics, catch_unwind returns Err. You map that to a safe error code. The app stays alive. You can log the error or retry.

Convention aside: The community calls this the "panic boundary" pattern. Every FFI crate should have a thin safe layer that wraps the unsafe logic with catch_unwind. It is the difference between a recoverable error and a total crash.

Pitfalls

Name mangling is the most common beginner mistake. You forget #[no_mangle]. The linker complains about undefined references. The error looks like undefined symbol: _ZN.... Add the attribute. The symbol appears.

Calling convention mismatches cause silent corruption. You omit extern "C". Rust uses the Rust calling convention. C++ expects C. Arguments end up in the wrong registers. The function reads garbage. The result is wrong. The crash happens later, far from the source. Always use extern "C".

Passing Rust types across FFI is undefined behavior. You cannot pass a String, a Vec, or a &str to C. These types have internal pointers and length fields. C does not know how to read them. If you pass a String, C sees a struct of three fields. It might read the length as a pointer and dereference it. Boom. Use only FFI-safe types: integers, floats, raw pointers, and arrays of primitives.

Compiler errors help here. If you try to pass a String where a *const c_char is expected, the compiler rejects you with E0308 (mismatched types). If you dereference a raw pointer without unsafe, you get E0133 (dereference of raw pointer requires unsafe). Trust these errors. They save you from undefined behavior.

Threading is another trap. React Native runs JavaScript on a dedicated thread. Native modules run on the native thread pool. If your Rust function blocks for a long time, it might block the UI thread. Offload heavy work to a Rust thread pool. Use tokio or std::thread. Return a future or a callback. Keep the FFI call fast.

Decision matrix

Use raw FFI with extern "C" when you are building a library that must compile to a static archive and link directly into the native module without extra tooling. This gives you maximum control and zero dependencies. You handle all type conversions and memory management yourself.

Use a Rust-to-JS binding crate like neon or napi-rs when you want to write Rust code that feels closer to JavaScript and handle type conversions automatically. These crates generate the C++ bridge for you. They support promises, buffers, and objects. You trade some control for developer experience.

Use pure JavaScript when the computation is light or involves DOM manipulation. The bridge has overhead. Crossing the bridge costs time. If the logic takes less than a millisecond, keep it in JavaScript. The bridge cost dominates.

Use std::panic::catch_unwind inside every FFI function when you cannot afford a panic to crash the entire application. React Native apps run on user devices. A crash loses data and frustrates users. Wrap the boundary. Return error codes.

Use std::os::raw::c_int and std::ffi::c_char in FFI signatures when you want to document that values cross a language boundary. These types are aliases on most platforms, but they signal intent to readers. They make audits easier.

Where to go next