← All writing

React Native

Across the Boundary: Moving Data Between React Native and Native with Callbacks

·13 min read


How data actually flows between your JavaScript and your native code — why callbacks are the only real currency across that boundary, how to pick the right shape for the job, and how a single value hops from deep inside a native SDK all the way to your app logic.


TL;DR

  • Two kinds of data cross the JS↔native boundary — a single result at the end of some work, and an open-ended stream of things happening over time. They call for two different primitives: a promise and an event channel.
  • Across that boundary you can't return a value or share memory. Everything arrives later, so a callback firing is the only way data comes back. Callbacks are the currency.
  • Getting a value from a native SDK to your app logic is a relay: each layer just calls the next callback. No layer knows the whole chain — which is exactly what makes it composable.

Sooner or later, a React Native app has to talk to native code that JavaScript can't reach — a platform capability, a vendor SDK, a piece of hardware. And the moment you do, you hit a question that's deceptively deep: how does data get back?

Let's ground it in one concrete feature. Imagine a native module that runs a modal purchase flow — JS asks it to start, the native side takes over the screen, the user does their thing, and it finishes. Two completely different kinds of data need to cross back into JavaScript:

  1. The final outcome. Did the purchase succeed, fail, or get cancelled? That's one value, delivered once, when the flow ends.
  2. A stream of analytics events. While the flow runs, the underlying SDK emits a running commentary — "screen viewed", "purchase started", "step completed" — that your app wants to forward to its analytics layer. That's many values, arriving over time.

Same boundary, same feature — but two fundamentally different shapes of data. Handle both well and the feature is clean; reach for the wrong tool and you get race conditions, leaks, and events that quietly go missing. So let's look at why the boundary behaves the way it does, then at the two tools for the two shapes.

Why the boundary is special

Your JavaScript and your native code run in separate worlds. They don't share memory, they don't share a call stack, and native work — presenting UI, hitting the App Store, talking to a device — is asynchronous. You cannot write const result = native.doTheThing() and get an answer on the next line, because the answer doesn't exist yet.

Everything that crosses the boundary crosses as a serializable message — plain data, copied from one side to the other — and it arrives later. Which means there is really only one mechanism for "getting data back":

The native side hands you a function to call when it has something, and calls it later.

That's a callback. Promises, event emitters, subscriptions — every "give me data from native" API you'll ever use is a callback wearing a nicer outfit. Once you internalize that callbacks are the only currency across the boundary, the design questions get simpler: they all become "what shape of callback fits this data?"

Primitive #1 — a single result: the promise

For our first data shape — one value, delivered once, when the work is done — the right tool is a promise. It's the callback shape for "do this, and tell me the single outcome."

From JavaScript, it reads like a normal async call:

// "Start the flow and tell me how it ended."
const status = await purchaseModule.startFlow(token);
// status: "success" | "cancelled" | "failed" | ...

On the native side, the module is handed a pair of callbacks — one to resolve with a result, one to reject with an error — and it holds onto them until the flow reaches an outcome, then calls exactly one of them:

native startFlow(token, resolve, reject):
    present the purchase UI
    when the flow ends with a status:
        resolve(status)      // fulfils the awaiting promise in JS
    if something blows up:
        reject(error)

The subtle, important part is "exactly one, exactly once." A modal flow can end many ways: the purchase completes, the user taps cancel, they swipe the sheet away, they background the app. All of those are the same outcome from JS's point of view — the promise settles once — but on the native side each is a separate code path that might fire. If two of them resolve, or one resolves twice, you get bugs that are miserable to trace.

So the native side needs a guard: a flag that says "I've already settled this" and makes every terminal path a no-op after the first. One outcome in, one settlement out.

settled = false
onAnyTerminalPath(status):
    if settled: return          // ← the whole trick
    settled = true
    resolve(status)

That's the entire story for request/response data: a promise is a one-shot callback, and your job is to make sure it fires once.

Primitive #2 — an open-ended stream: the event channel

Our second data shape is different in kind. The analytics events aren't one result at the end — there's an unknown number of them, arriving while the flow runs. A promise is the wrong tool: a promise settles once and is done. You need something that can deliver values repeatedly, for as long as you're interested.

That's an event channel: you hand the native side a listener callback, it calls that listener each time something happens, and it gives you back a way to say "I'm done listening."

// Subscribe: "call me every time an analytics event happens."
const subscription = purchaseModule.onEvent(event => {
  forwardToAnalytics(event);
});

// Later, when you no longer care (e.g. the screen unmounts):
subscription.remove();

Two properties make this the right shape:

  • It's many-shot. The listener fires 0, 1, or 100 times. Perfect for "an unknown number of things over time."
  • It's cancellable. The subscription hands back an unsubscribe. Forgetting to call it is the classic native-bridge leak — a listener that outlives the screen that created it, holding references (and firing into dead UI) forever. Always tear it down.

The rule: match the primitive to the data shape

Step back and the design rule is simple. Before you wire anything across the boundary, ask what shape is this data? — and let that pick the tool:

The data is…Use…Why
One value, available now, synchronouslya return valuerare across the boundary; only for cheap, already-known data
One value, produced later (async work)a promisesettles once; await-able; models "do X, tell me the result"
An unknown number of values, over timean event channelmany-shot + cancellable; models "notify me whenever…"
A result specific to this calla callback argumentties the answer to the exact invocation that asked

Most cross-boundary pain comes from a shape/tool mismatch — polling for something that should be an event, or firing a stream of events for something that should have been a single promised result. Get the shape right and the rest falls into place. Our purchase feature needs both rows: a promise for the outcome, an event channel for the analytics stream.

The relay: following one event across the boundary

Now the part I find most illuminating. Let's follow a single analytics event from where it's born to where it's consumed, and watch what actually carries it.

The event starts life deep inside the native SDK — far from JavaScript. It reaches your app logic through a chain of hops, and at every hop, one layer hands the value to the next by calling a callback:

The callback relay across the native/JS boundary: a native SDK emits an event, calling the stored forwarder callback in the native module, which emits the payload over the event channel to JS; the JS listener enriches and routes it, calling track on the analytics layer that fans out to providers

Walk it hop by hop:

  1. The SDK accepts a closure. Deep in native code, the SDK is configured with "here's a function — call it whenever something analytics-worthy happens." It doesn't know or care where that function leads.
  2. The native module stores a forwarder. The module keeps that closure and, when the SDK invokes it, does one tiny thing: push the payload onto the event channel toward JS.
  3. The event channel delivers to the JS listener. The payload crosses the boundary as plain data and lands in the listener you subscribed with.
  4. JS routes it onward. Your listener hands the event to the analytics layer, which itself fans out by calling each provider — another set of callbacks.

Here's the insight worth carrying away: it's callbacks all the way down, and no single layer knows the whole chain. The SDK only knows "call the function I was given." The native module only knows "when my forwarder is called, emit." The JS listener only knows "when I receive an event, route it." Each layer is coupled only to the next callback — not to the source, not to the destination.

That's not an accident; it's the property that makes the whole thing composable. You can swap the analytics layer without touching the native module. You can reuse the native module in another app with a completely different consumer. Every hop is a seam, because every hop is just "call the next callback."

Keep the transport dumb

Notice what the native module in that relay does not do: it doesn't rename the event, look up its "official" name, attach standard attributes, or know anything about your analytics taxonomy. It forwards a raw { id, data } and gets out of the way.

That's deliberate. The boundary should be dumb transport; the meaning lives in JS. All the logic that changes often — what an event is called, which attributes it carries, how it maps to your analytics vendors — stays on the JavaScript side, where it's cheap to change and easy to test. The native module becomes thin, generic, and reusable: it moves bytes, nothing more.

// The native side sends this — raw and meaningless on its own:
{ id: "purchase_started", data: { productId: "..." } }

// JS gives it meaning: names, standard attributes, config-driven mapping:
forwardToAnalytics(enrich(rawEvent));

Push business logic toward the language that's easiest to change, and keep the expensive-to-ship native layer as simple as you possibly can.

Correlating a stream with the call that started it

One more real-world wrinkle worth naming. The analytics events arrive on a side channel — the event channel — with no built-in reference to the JS call that kicked off the flow. But your app often wants to tag those events with context that only the caller knew: where the user tapped to open the flow, what screen they came from, and so on.

You could thread that context down through the native call and back up through every event — but that's a lot of plumbing across the boundary for data that never needed to leave JS. A lighter pattern: stash the context on the JS side before you make the call, and read it when events arrive.

let pendingContext = {};

async function startPurchase(token, context) {
  pendingContext = context;              // remember why we opened this
  try {
    return await purchaseModule.startFlow(token);
  } finally {
    pendingContext = {};                 // clear when the flow ends
  }
}

// In the event listener:
purchaseModule.onEvent(event => {
  forwardToAnalytics(enrich(event, pendingContext));  // correlate
});

It works because the events only arrive between the call starting and finishing, and the flow is modal and serial — one purchase at a time. That's the crucial caveat: this pattern is safe precisely because two of these flows can't overlap. If your flows could run concurrently, this shared slot would cross wires, and you'd need to correlate with an explicit id instead. Know why the shortcut is safe before you take it.

Getting the callbacks right

The failure modes across the boundary are consistent, and so are the defenses:

  • Fire terminal callbacks exactly once. Guard every promise resolve/reject behind a "settled" flag; a modal flow has more ways to end than you think.
  • Always unsubscribe. Tie an event subscription's lifetime to whatever created it (a screen, a component) and tear it down on teardown. Leaked listeners fire into the void and hold memory.
  • Don't leak references the other way, either. On the native side, don't let a long-lived SDK hold a strong reference to a short-lived object through your forwarder — use a weak reference so the boundary doesn't pin objects alive.
  • Keep payloads small and serializable. Everything crossing the boundary gets copied. Send plain data (ids, primitives, small objects), not rich objects or handles.
  • Type the contract. The two sides agree on a shape by convention; make that convention explicit and shared so a change on one side is a visible break on the other, not a silent runtime surprise.

What this gets you

Treat the boundary as "callbacks in the right shape," and a lot of good properties come for free:

  • The right ergonomics per data shapeawait for outcomes, subscriptions for streams — so call sites read the way the data actually behaves.
  • A thin, reusable native module that transports bytes and holds no business logic.
  • Composability — every hop is a swappable seam, because every hop is just "call the next callback."
  • Fewer heisenbugs — the classic double-resolve and leaked-listener failures are designed out, not debugged after the fact.

The next time you bridge to native, don't start with the API. Start with the shape of the data — one value later, or many over time? — pick the matching primitive, and remember that from the SDK all the way to your app logic, you're just building a relay where each layer calls the next. Get those two things right and the boundary stops being scary.


Related reading: once these events reach JavaScript, where do they go? See the companion series on building a provider-agnostic, server-driven analytics layer — the "fan-out to providers" at the end of the relay.

Written by Tushar Dahiya. If this was useful, find more of my writing at tushardahiya.com — more on React Native and cross-platform architecture.

Code in this article is illustrative pseudo-code meant to convey the pattern, not a specific framework API, library, or vendor.