React Native’s New Architecture changes the semantics of how JavaScript sees native modules, not just the performance profile. Mature apps break because code that relied on legacy Bridge-era runtime behaviour (plain objects, eager module shape, “constants as properties”, sync-like assumptions) now runs against prototype-based, lazily loaded TurboModules via the interop layer.
If you’re upgrading an established codebase, the migration risk is less “can we flip the flag” and more “what invisible assumptions have we baked into app code and third-party wrappers”. The interop layer buys you time, but it also hides sharp edges until production.
What actually changed (the parts that cause subtle breakage)
The New Architecture introduces a new native module system (TurboModules), a new renderer (Fabric), and a JSI-based communication path that supports synchronous access patterns where appropriate. React Native explicitly calls out that the new module system enables synchronous access to the native layer and that modules are lazily loaded by default. (reactnative.dev)
To ease migration, React Native ships a Native Module Interop Layer so older modules can keep working while you move incrementally. The working group guidance is blunt: from React Native 0.74 the interop layer is enabled by default, and in New Architecture mode “it is expected” that bridgeless is enabled. (github.com)
That combination (interop + lazy loading) is where most “how is this undefined” bugs come from:
- JavaScript no longer necessarily receives a plain object of enumerable functions.
- Module method resolution may happen via prototypes.
- “Constants” stop behaving like a one-time initialisation export.
- Code that copies, spreads, enumerates, or memoises a module shape can accidentally drop the real methods.
Trap 1: TurboModules via interop are prototype-based, so your object tricks stop working
A very common pattern in older apps and libraries is to take a NativeModule and wrap/augment it:
- spread the native module into an object and add helpers
- enumerate keys to build a proxy
- serialise module shape for debugging
- destructure methods once and pass them around
Under the interop layer, this can fail because TurboModules use object prototypes to lazily load methods. React Native’s own New Architecture working group documentation calls this out directly: operators like the spread operator or Object.keys on NativeModules.YourModule can break, and the recommended workaround is to use Object.create with property descriptors. (github.com)
The failure mode
XYZ is not a function (it is undefined)only in New Architecture / bridgeless builds.- Wrapper objects appear to have “lost” methods.
- Debug output makes it look like the module has no keys.
What to do instead
- Avoid spreading native modules.
- Avoid relying on
Object.keys()/for…into discover methods. - If you need an augmented module object, prefer
Object.create(NativeModules.X, descriptors)as per the working group guidance. (github.com)
Quick static detection
Run targeted searches (ripgrep/VS Code) for patterns that are strongly correlated with this bug:
...NativeModules.Object.keys(NativeModules.Object.assign({}, NativeModules.for (const k in NativeModules.const X = { ...NativeModules.
When you find them, don’t assume they are harmless “wrappers”. Under interop they can change behaviour.
Trap 2: “Constants are properties” stops being a safe assumption
A lot of apps read native constants as if they are stable properties:
NativeModules.SomeModule.DEFAULT_TIMEOUTSomeModule.DEFAULT_TIMEOUT
React Native’s docs have been warning for a while that although it’s “technically possible” to access constants directly off the module object, it will no longer be supported with TurboModules and you should switch to calling getConstants(). (reactnative.dev)
More importantly, the meaning of constants changes. In the legacy architecture, constants were effectively exported at initialisation time: you got a snapshot. With TurboModules, getConstants() becomes a regular native method, and each invocation will hit the native side. (reactnative.dev)
The failure modes you actually see
- Code that relies on constants being present as enumerable properties breaks.
- Code that calls
getConstants()repeatedly (e.g. on every render) suddenly introduces avoidable native calls. - Code that assumed constants cannot change at runtime is now wrong (because you can now fetch different values later). (reactnative.dev)
Do not do this
- Don’t read module constants directly as properties in new code.
- Don’t call
getConstants()inside hot paths (render loops, tight polling) without caching.
Safer patterns
- Call
getConstants()once at module initialisation and cache in JS if the values are truly static. - If the values can vary (locale, configuration, permissions), treat them as dynamic and fetch when needed — but make that decision explicit.
Trap 3: “Sync-ish” module calls and startup ordering assumptions get exposed
The New Architecture makes synchronous native access possible in ways the bridge never allowed, and React Native positions that as a capability of the new native module system. (reactnative.dev)
The trap is that many mature apps already have code that behaves as if native calls are synchronous — usually by accident:
- reading a value at module import time
- calling a native method before the module is actually ready
- assuming that if a module exists, all its methods are immediately callable
With lazy loading, “exists” and “ready” are no longer the same thing. In bridgeless + interop setups, you can also get different behaviour between debug and release builds, because bundling, init timing, and code paths differ.
Symptoms
- Works on legacy architecture; fails only on New Architecture.
- Works on simulator/dev client; fails on release builds.
- Rare startup crashes:
TurboModuleRegistry.getEnforcing(...): 'X' could not be found(often a linking/config issue, but surfaced earlier due to different startup characteristics).
Practical rule
If you call into native at module top-level (during import), treat that as a bug until proven otherwise. Move the call behind an explicit initialisation boundary (first app screen, a bootstrap step, or a lazy getter).
Trap 4: “NativeModules is a dictionary” assumptions leak into app architecture
A surprising amount of app code treats NativeModules as a stable dictionary that you can:
- snapshot (
const modules = {...NativeModules}) - iterate and register
- pick/omit keys
- patch in test doubles by reassigning properties
Under interop, those patterns are fragile because the module objects are not plain enumerable bags of functions. React Native explicitly points out that some operators won’t work when a module is accessed directly from NativeModules in the New Architecture, due to prototype-based lazy method loading. (github.com)
Do not do this
- Don’t copy
NativeModulesor a module object to create a new “pure JS façade” via spread/assign. - Don’t use
Object.keysto decide what a module supports.
Prefer this
- Import your module wrapper as a dedicated file, expose a narrow typed API, and keep the native object private.
- For tests, inject that wrapper, rather than monkey-patching
NativeModulesglobally.
Detection tactics that work in real codebases
A checklist won’t catch semantic changes, so treat this as a short investigation sprint.
1) Static searches (fast signal)
Run these across app code and any in-repo libraries:
- Spread/assign/keys on native modules:
...NativeModules.Object.keys(NativeModules.Object.assign(withNativeModules.
- Constants accessed as properties:
.DEFAULT_.constantsNativeModules.*\.(manual review for constant-like property reads)
- Top-level native calls (import-time side effects):
- files that do
const x = NativeModules.Y.someCall()outside a function
2) Targeted runtime assertions (catch it before production)
Add small guards in development builds:
- Assert you never spread a module:
- Wrap known modules with a helper that throws if someone tries to enumerate keys in an unsupported way (e.g. by checking for zero enumerable keys but presence on prototype).
- Assert constants usage:
- In wrappers, export constants only via a
getConstantsCached()function and deprecate property reads.
- Assert initialisation order:
- In app bootstrap, explicitly call “safe” no-op methods (or
getConstants()) and fail early if modules are missing.
This isn’t about adding permanent overhead. It’s about turning “undefined is not a function” into a controlled crash during QA.
3) Test in the mode you will ship
The New Architecture working group guidance effectively treats bridgeless as the expected configuration when the New Architecture is enabled (from 0.74 onwards). (github.com)
So don’t sign off on a migration based on “New Arch on, bridgeless off” if your production plan is bridgeless.
When the right answer is: do not migrate yet
You can ship a stable New Architecture app today, but there are cases where pressing ahead is a poor use of engineering time:
- You depend on a business-critical native library that is unmaintained and fails under interop.
- Your app relies heavily on monkey-patching
NativeModules(common in older test setups) and you can’t justify the refactor. - You have multiple custom native modules with complicated initialisation side effects and no time to build a proper bootstrap boundary.
React Native’s own blog post frames the interop layer as a gradual migration tool, but also states that in a future release the interop layer will be removed and modules will need to support the New Architecture. That is a real clock, even if the date is not your immediate concern. (reactnative.dev)
A practical path for teams shipping mature apps
If you want a plan that avoids the most expensive class of bugs (late-breaking runtime semantics), do this in order:
- Make a list of every direct
NativeModules.*access in your repo. - Replace each with a wrapper module that: - calls
getConstants()explicitly - does not spread/assign/enumerate the native module object - exposes a small typed surface - Add a dev-only assertion layer that validates module presence at startup in bridgeless builds.
- Only then move on to “real” New Architecture work (codegen specs, TurboModule conversion, Fabric components).
This sequence is boring, but it is how you stop interop from turning into a whack-a-mole exercise.
Where Codeversols fits (briefly, honestly)
If you’re mid-upgrade and it’s already messy, the highest leverage help is rarely “someone who knows the flags”. It’s someone who can read your existing native/module boundaries, find the accidental semantics you rely on, and refactor toward wrappers and codegen-friendly APIs without breaking delivery. That’s the kind of migration and stabilisation work we do at Codeversols across mobile, web, and cloud, usually alongside feature work so the app doesn’t stall.
Close
The New Architecture is not just “faster React Native”. It changes how native modules behave at runtime, and the interop layer can make those changes feel random until you recognise the patterns: prototype-based module objects, constants semantics shifting, and lazy-loading disrupting import-time assumptions.
Treat this as a semantics audit, not a checkbox migration. Do the static searches, add a few targeted assertions, and you’ll turn the upgrade from a nervous flip of a default into an engineering task you can actually schedule and ship.



