One Constructor Argument, Two Incompatible Contracts: NativeEventEmitter Across the React Native Bridge Divide

There is a specific kind of bug that only exists during a migration, and it has a signature: two things are individually correct, and you cannot have both.

NativeEventEmitter in React Native is one of those. Pass its constructor a native module and the New Architecture complains at you forever. Omit the argument and the legacy bridge crashes your app on launch, on iOS, immediately. The library is not broken in either case. It is broken because it has to satisfy two contracts that were written years apart and disagree.

The crash

Under the legacy bridge, the iOS NativeEventEmitter constructor enforces a hard invariant:

invariant(nativeModule != null, "...", ...);

So a library that does the natural-looking thing —

new NativeEventEmitter();

— throws on construction. What the user sees is not a helpful invariant message but this, at startup:

Cannot read property 'addEventListener' of undefined

That message sends people hunting through their own listener code, which is the wrong place. Nothing is wrong with addEventListener. The emitter never got built.

Why anyone would omit the argument in the first place

Here is the part that makes it a genuine dilemma rather than a simple mistake.

Under the New Architecture (and Bridgeless mode), if you do pass a non-null module, React Native probes that module's JS surface for addListener and removeListeners. Plenty of native modules never declared those — they were never needed under the old bridge. When they are missing, RN emits DEV-mode warnings about the incomplete module surface.

A detail worth getting right, because I had it wrong until I opened the framework source: those console.warn calls live inside the NativeEventEmitter constructor, not inside addListener (Libraries/EventEmitter/NativeEventEmitter.js, checked against RN 0.86.2). So the warning fires once per emitter you construct — not once per listener you register. If your library builds one emitter at module scope, that is two warnings at import time, every time, forever. Annoying enough to make someone want to silence it; not the flood it is sometimes described as.

So the maintainer of a library supporting both worlds gets pushed in two directions at once:

pass the module omit the module
Legacy bridge (iOS) works crashes at startup
New Architecture warnings at construction quiet

Someone tired of seeing those warnings does the obvious thing, drops the argument, ships it — and now every legacy-bridge iOS user crashes on launch. The fix for one contract is the bug for the other. That is what makes this worth writing down: the failure is not carelessness, it is a reasonable local decision with a non-local consequence.

The fix is to satisfy both, not to choose

The way out is to stop treating it as a choice. Restore the constructor argument — that is non-negotiable, the legacy bridge will not budge — and then make the module look the way the New Architecture wants before you construct the emitter:

const DaroMModule = NativeModules.DaroMModule;

// Polyfill to satisfy New Architecture checks and silence warnings
if (DaroMModule) {
  DaroMModule.addListener = DaroMModule.addListener || (() => {});
  DaroMModule.removeListeners = DaroMModule.removeListeners || (() => {});
}

export const EventEmitter = new NativeEventEmitter(DaroMModule);

Three details in there earn their place:

The if (DaroMModule) guard. If the native module is genuinely absent — someone forgot to link it, or you are running in an environment where it does not exist — you want the honest downstream failure, not a TypeError thrown while assigning properties to undefined.

The || fallbacks rather than plain assignment. If the module already implements these, keep the real implementations. You are filling a hole, not paving over the floor.

The ordering. The polyfill has to run before the constructor. The probe happens at construction time; patching afterwards is patching a decision that has already been made.

The no-op bodies are the right shape here because the methods exist to satisfy a presence check on the JS surface. The legacy bridge routes events through its own path regardless.

What this generalizes to

I would not remember addListener and removeListeners in six months. What I want to keep is the shape of the problem, because RN's New Architecture migration is full of it:

When two runtimes disagree, look for the adapter before you look for the switch. The instinct is a version check — "if new arch, do this; else do that." Branching on the runtime doubles your paths and both of them rot. Making the object satisfy both contracts keeps one path.

A startup crash that names one of your own APIs may be about a constructor that never ran. Cannot read property 'X' of undefined reads like "X is broken." It usually means the thing that was supposed to hold X was never built. Read the construction site, not the usage site.

A warning you want to silence is still a bug report. The construction-time warnings under the New Architecture were correct — they were telling the library its module surface was incomplete. Silencing them by removing the argument treated a symptom as noise, and the treatment was worse than the disease.

One honest caveat about scope. I checked the warning cadence against RN 0.86.2, and the library records the legacy-bridge behavior as verified on RN 0.79 and prior. That is two points, not a matrix — I have not established when the probe behavior changed, or how it behaves at every release in between. If you hit something adjacent, test it against the RN version you actually ship rather than trusting the table above to hold at your pins.