React Native New Architecture: legacy native module is null after upgrade
After upgrading to React Native 0.79 with the New Architecture on by default, our in-house printer module came back null on iOS and crashed on Android. Bridge-only registration and direct bridge calls were the cause; here is the migration path.
Our point-of-sale app for small cafés talks to Bluetooth receipt printers through an in-house native
module, ReceiptPrinter, written in Objective-C and Kotlin back in 2021. We upgraded from React
Native 0.73 to 0.79 in one jump, partly to get off an unsupported version and partly because the
New Architecture, enabled by default since 0.76, promised faster startup.
The app launched, navigation worked, and the first test print failed. On iOS, the JavaScript wrapper threw our own guard error:
Error: ReceiptPrinter native module is not available. Did you rebuild the app?
at getPrinter (src/native/printer.ts:12:11)
NativeModules.ReceiptPrinter was null. On Android the module existed, but the first status
callback from the printer crashed the app with an exception from inside our module, where it called
reactContext.getCatalystInstance() to emit an event.
Reproducing it
- Build the app with React Native 0.79 and the default
newArchEnabled=true(Android) andRCT_NEW_ARCH_ENABLED=1(iOS, the default for pod install). - iOS: open the printer screen. The module is
nullimmediately. - Android: connect to a printer, or call the module's
simulateStatus()debug method. The app crashes when the module emits its first event. - Rebuild with the New Architecture disabled and both problems disappear.
What we ruled out
- A missing rebuild. The classic cause of a
nullmodule is a JS-only reload after adding native code. We did clean builds,pod install, and./gradlew clean. - Module name mismatch.
RCT_EXPORT_MODULE(ReceiptPrinter)and Kotlin'sgetName()both returnedReceiptPrinter, and the same names worked in the old architecture build. - The interop layer being off. Since 0.74 the interop layer for legacy modules is automatic, and our other legacy module, a simple haptics helper, worked fine under the New Architecture.
Root cause
Our two failures came from code that depended on the bridge itself, which does not exist in bridgeless mode:
- iOS: the module needed a configuration object at init, so our
AppDelegatecreated it manually and handed it to React Native throughextraModulesForBridge:, anRCTBridgeDelegatemethod. In bridgeless mode there is no bridge and no bridge delegate, so that method is never called and the module never got registered. - Android: the module emitted events with
reactContext.getCatalystInstance().getJSModule(...). The catalyst instance is a bridge concept; the React Native working group's bridgeless notes list directgetCatalystInstance()access as unsupported and recommend calling the equivalent methods onReactContext.
What is happening under the hood
The New Architecture replaces the asynchronous JSON bridge with JSI: JavaScript holds direct references to C++ host objects. Native modules become TurboModules, which are loaded lazily on first access and typed by a codegen spec. "Bridgeless mode" is the final step, where the bridge object is gone entirely.
To make migration possible, React Native ships an interop layer that exposes legacy modules,
the ones using RCT_EXPORT_MODULE or ReactContextBaseJavaModule, as if they were TurboModules.
It covers the common cases well: exported methods, promises, callbacks and constants. It cannot cover
code that reaches past the module API into the bridge:
| Legacy pattern | Under the New Architecture |
|---|---|
RCT_EXPORT_MODULE() with default init |
Works through interop |
Module instance created in extraModulesForBridge: |
Never registered, JS sees null |
self.bridge on iOS |
A bridge proxy object that supports most, not all, legacy calls |
reactContext.getCatalystInstance() on Android |
Not supported in bridgeless mode |
reactContext.getJSModule(RCTDeviceEventEmitter::class.java) |
Works |
Environment
| Component | Version / value |
|---|---|
| React Native | 0.73.6 before, 0.79.3 after (New Architecture on) |
| React | 19.0.0 |
| JS engine | Hermes (bundled with React Native) |
| iOS | Xcode 16.3, iOS 18.5 devices, CocoaPods 1.16.2 |
| Android | AGP 8.8, Kotlin 2.0.21, targetSdk 35 |
| Module | ReceiptPrinter, Objective-C on iOS, Kotlin on Android, no codegen spec |
Minimal reproduction
// AppDelegate.mm (excerpt, old architecture pattern)
- (NSArray<id<RCTBridgeModule>> *)extraModulesForBridge:(RCTBridge *)bridge
{
PrinterConfig *config = [PrinterConfig configWithVendorKey:@"pk_test_xxx"];
return @[ [[ReceiptPrinter alloc] initWithConfig:config] ];
}
// ReceiptPrinterModule.kt (excerpt)
private fun emitStatus(status: WritableMap) {
reactApplicationContext.catalystInstance
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("printerStatus", status)
}
Who is affected
Apps upgrading to React Native 0.76 or newer with the New Architecture enabled that contain in-house or older third-party native modules with custom registration, direct bridge access, or bridge-specific APIs. Well-maintained libraries have mostly migrated; check the React Native Directory's New Architecture flag for each one before upgrading.
We took a two-step path: first make the legacy module work through the interop layer by removing bridge dependencies, then convert it to a typed TurboModule in a later release.
The fix
iOS: let React Native create the module, and inject configuration differently. We removed the
extraModulesForBridge: override and moved the configuration to a small shared store that the app
fills before React Native starts:
// PrinterConfigStore.h
@interface PrinterConfigStore : NSObject
+ (void)setSharedConfig:(PrinterConfig *)config;
+ (PrinterConfig *)sharedConfig;
@end
// ReceiptPrinter.mm
@implementation ReceiptPrinter
RCT_EXPORT_MODULE(ReceiptPrinter)
- (instancetype)init
{
if (self = [super init]) {
_config = [PrinterConfigStore sharedConfig];
NSAssert(_config != nil, @"PrinterConfigStore must be set before React Native starts");
}
return self;
}
+ (BOOL)requiresMainQueueSetup { return NO; }
@end
// AppDelegate.mm, before the React Native factory starts
[PrinterConfigStore setSharedConfig:[PrinterConfig configWithVendorKey:vendorKeyFromKeychain()]];
React Native now instantiates the module itself through the interop layer, on first access, and the module finds its configuration ready.
Android: emit events through ReactContext, not the catalyst instance.
private fun emitStatus(status: WritableMap) {
- reactApplicationContext.catalystInstance
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
- .emit("printerStatus", status)
+ reactApplicationContext
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
+ ?.emit("printerStatus", status)
}
We also added addListener and removeListeners methods to the Android module, which
NativeEventEmitter expects, to silence its warnings.
Next release: a real TurboModule. We added a codegen spec so the module is typed and loaded directly, without the interop layer:
// src/native/NativeReceiptPrinter.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
connect(address: string): Promise<boolean>;
print(jobJson: string): Promise<string>;
addListener(eventName: string): void;
removeListeners(count: number): void;
}
export default TurboModuleRegistry.getEnforcing<Spec>('ReceiptPrinter');
getEnforcing throws a clear error at import time if the module is missing, instead of returning
null and failing later at the call site.
Alternatives and trade-offs
- Disable the New Architecture temporarily. Setting
newArchEnabled=falseingradle.propertiesand installing pods withRCT_NEW_ARCH_ENABLED=0restored the old behaviour in 0.79. We used it for one internal build while fixing, but the legacy architecture is frozen, and new library versions increasingly require the new one. - Keep the module legacy indefinitely. The interop layer is a migration tool, not a permanent home. Converting a small module with a spec is a day of work.
- Rewrite the module as an Expo Module. A good option for teams already on Expo's tooling; we are not, so we stayed with codegen.
How to test the fix
We added a native module smoke test to our Detox suite that runs on both platforms, with the New Architecture on, against a mocked printer transport:
// e2e/printer.e2e.ts
it('connects and reports status events', async () => {
await element(by.id('printer-settings')).tap();
await element(by.id('connect-mock-printer')).tap();
await expect(element(by.id('printer-status'))).toHaveText('ready');
});
npx detox build -c ios.sim.release && npx detox test -c ios.sim.release e2e/printer.e2e.ts
npx detox build -c android.emu.release && npx detox test -c android.emu.release e2e/printer.e2e.ts
We also checked at runtime that the app was really bridgeless, by logging
global.RN$Bridgeless === true at startup in debug builds.
Confirming in production
- We rolled out to 20 cafés first. Print success rate, which we log per job, stayed at 99.4 percent, identical to the previous version.
- No crashes from
ReceiptPrinterModuleon Android in Crashlytics during the staged rollout or after 100 percent. - Cold start time on a mid-range Android tablet fell from 2.9 s to 2.4 s at the median, part of the reason for the upgrade.
Regression guard
- The Detox printer test runs on every pull request for both platforms with the New Architecture on.
- A lint rule bans
catalystInstanceandgetCatalystInstancein Kotlin sources, and a grep in CI fails ifextraModulesForBridgereappears in iOS code. - New native modules must start with a codegen spec; legacy modules need an architecture review.
Security implications
The old code passed a vendor key into the module from AppDelegate, which tempted earlier developers
to hard-code it. As part of the fix the key now comes from the Keychain at startup and only lives in
the config store in memory. Interop and TurboModules do not change the permission model: any module
reachable from JavaScript is callable by any JavaScript in the bundle, so keep native methods narrow
and validate their inputs, especially for modules that talk to hardware.
Prevention checklist
- Before upgrading React Native, audit native modules for bridge access, custom registration and bridge delegate methods.
- Use
TurboModuleRegistry.getEnforcingso missing modules fail loudly at startup. - Write codegen specs for in-house modules instead of relying on the interop layer.
- Test native modules end to end with the New Architecture on, on both platforms.
- Check each third-party library's New Architecture support before the upgrade.
References
- React Native blog: The New Architecture is here (0.76) — https://reactnative.dev/blog/2024/10/23/the-new-architecture-is-here
- React Native docs: Turbo Native Modules — https://reactnative.dev/docs/turbo-native-modules-introduction
- React Native New Architecture working group: Bridgeless mode interop and known limitations — https://github.com/reactwg/react-native-new-architecture/discussions/154
- React Native docs: Upgrading to new versions — https://reactnative.dev/docs/upgrading
Add a label
Embed this post
<iframe src="https://hibug.me/react-native-new-architecture-native-module-null" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.