Skip to content

React Native: "main" Has Not Been Registered — Fix

Invariant Violation: "main" has not been registered — here's exactly why AppRegistry throws this on iOS and Android in RN 0.79–0.87, and how to fix it fast.

react-native react-native-errors appregistry expo metro hermes new-architecture troubleshooting
Bharath G
Reading Progress

On This Page

1. The Error

You launch the app — npx react-native run-android, npx expo run:ios, or a TestFlight build someone else installed — and instead of your UI you get a red screen (or, in release, a silent crash) with:

Invariant Violation: "main" has not been registered. This can happen if:
* Metro (the local dev server) is run from the wrong folder. Check if Metro is running, and if it's running from the right project.
* A module failed to load due to an error and `AppRegistry.registerComponent` wasn't called.

This error is located at:
    in RCTView
    in AppContainer
    in main(RootComponent)

    at node_modules/react-native/Libraries/ReactNative/AppRegistry.js:112:23 in runApplication
    at node_modules/react-native/Libraries/ReactNative/AppRegistry.js:45:4 in runnables.main.run
    at node_modules/react-native/Libraries/Utilities/HMRClient.js:459:12 in registerBundle

The quoted string is not always "main" — it's whatever appKey your app is supposed to register, so you'll also see it as "myAppName" or your Expo app.json name. A close cousin fires slightly earlier in boot:

Invariant Violation: Module AppRegistry is not a registered callable module (calling runApplication)

Both say the same thing at different layers: native asked JS to run a component under a given key, and JS has no record of that key ever being registered.

Where it differs by version and platform: Legacy and New Architecture (bridgeless, 0.76+) phrase the message identically — AppRegistry.js's invariant text hasn't changed — but bridgeless fails faster with a shorter native stack, since there's no bridge round-trip masking the timing. In debug, you get the full bulleted message in Metro's red box/LogBox. In release, LogBox doesn't exist — Hermes throws the same Invariant Violation, but you'll see it as a bare uncaught exception in adb logcat or the Xcode/Console.app device log, often with no stack if you didn't ship a Hermes source map; on iOS it frequently just looks like a blank white screen with nothing attached to read the console. On Expo (managed, dev client, or bare with CNG), the wording is identical because registerRootComponent is a thin wrapper that still calls AppRegistry.registerComponent('main', ...) — Expo apps almost always register under the literal key "main", where bare RN CLI apps use whatever name you gave init.

Confirmed against the current AppRegistry.js invariant text, reactnative.dev's AppRegistry docs, and Expo's troubleshooting page for this error (docs.expo.dev/troubleshooting/application-has-not-been-registered) as of React Native 0.87 / Expo SDK 57.

2. How to Reproduce It (step-by-step)

The fastest, most honest repro is to break the thing that's actually breaking in the wild: an exception thrown during module evaluation, before AppRegistry.registerComponent runs.

Bare React Native CLI:

bash

npx @react-native-community/cli@latest init Repro --version latest
cd Repro

package.json (relevant slice):

json

{
  "dependencies": {
    "react": "19.2.0",
    "react-native": "0.87.0"
  }
}

Edit index.js to throw during import evaluation, simulating a bad top-level statement in a module you import before registration:

js

// index.js
import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';

// Simulate a module-init crash — e.g. a badly-linked native module
// reading a native constant that doesn't exist yet.
import { NativeModules } from 'react-native';
NativeModules.SomeNotLinkedModule.doThing(); // throws before registerComponent runs

AppRegistry.registerComponent(appName, () => App);

Run it:

bash

npx react-native start --reset-cache
npx react-native run-android

Result: Metro logs the thrown TypeError: Cannot read property 'doThing' of undefined for a split second, then the app shows Invariant Violation: "Repro" has not been registered — the throw happened before the last line ran, so registerComponent genuinely never executed.

Expo:

bash

npx create-expo-app@latest repro --template blank-typescript
cd repro
npx expo install expo@~57.0.0

app.json slice:

json

{ "expo": { "name": "repro", "slug": "repro" } }

Same trigger, at the top of App.tsx, reproduces the identical "main" has not been registered message, because expo's entry ("main": "expo-router/entry" or node_modules/expo/AppEntry.js) calls registerRootComponent(App)AppRegistry.registerComponent('main', ...) at the bottom of the same module graph.

Non-crash triggers, each independently reproducible:

  • Wrong-folder Metro: cd into a different RN project, run npx react-native start there on port 8081, then run-android from Repro. The app connects to the wrong Metro, which has no Repro bundle registered under that key.
  • AppKey mismatch after a rename: rename the app in app.json/package.json but leave MainActivity.kt's getMainComponentName() (or iOS AppDelegate.swift's moduleName) pointing at the old string — native asks for a key JS never registered.
  • Stale Metro cache after a JS-side rename: rename the JS appKey in index.js without clearing cache — Metro serves a cached bundle built under the old key.

Environment: Node 22.14, RN CLI 20.x, Android SDK 35/36 emulator, Xcode 16.x simulator. npx react-native info should show React Native 0.87.x / React 19.2.x for the version-specific wording here to apply exactly.

3. Version Behaviour Matrix

This error is essentially version-neutral — the invariant text and root cause haven't changed since the bridge era, because AppRegistry is one of the few APIs New Architecture didn't touch. What has changed is how you get into the broken state and how much diagnostic signal you get out:

ReleaseBehaviour
0.76 (Oct 2024)New Architecture on by default; bridgeless startup means a module-init throw surfaces with a shorter, more direct native stack than the old bridge did.
0.79 (Apr 2025)Faster CLI/tooling; no change to the message or cause.
0.80 (Jun 2025)Deep-import deprecation warnings begin — a deep import into react-native/Libraries/... that used to silently work can now warn (not yet fail) during module init; a badly-behaved warning-to-error lint rule can turn this into the same crash-before-registration failure.
0.81 (Aug 2025)No change to this path.
0.82 (Oct 8, 2025)New Architecture only — an old-arch-only native module that used to fail softly on a Fabric-only build can now throw hard during its native-module init, which JS surfaces as this same invariant if the failure happens synchronously enough to precede registration.
0.83 (Dec 10, 2025)No user-facing breaking changes; unrelated to this error.
0.84 (Feb 11, 2026)Legacy Architecture components fully removed — a library still calling a removed legacy API at module scope now throws at import time instead of at render time, which is a new way to hit this same message.
0.85 (Apr 7, 2026)No change to AppRegistry; Jest preset move (@react-native/jest-preset) is unrelated but commonly confused with this error in test environments that also fail to "register" a root component under Jest.
0.86 (Jun 11, 2026)No user-facing breaking changes.
0.87 (Aug 11, 2026)Strict TypeScript API default — a deep import that's now a type error can still work at runtime, so this doesn't newly cause the crash, but it does mean a tsc-clean app is less likely to hit the "module failed to load" branch from a bad deep import.

Framework layer: Expo SDK 53 → 57 all wrap the same registerComponent('main', ...) call via registerRootComponent; the SDK doesn't change this error's wording, only how you reach it — expo-router/entry module-graph failures are the most common Expo-side trigger, since the router's file-based route tree is evaluated before registration completes.

4. Why It Happens — Surface Level

Something on the JS side never called AppRegistry.registerComponent(appKey, ...) with the exact key the native side is asking for, or it called it and then Metro/the bundle serving that call to the device is stale or pointed at the wrong project. The native shell doesn't know why — it just knows it asked runApplication(appKey) and AppRegistry's internal runnables map has no entry for that key, so it throws.

5. Why It Happens — Under the Hood

AppRegistry is a plain JS module holding a runnables dictionary keyed by appKey. registerComponent(appKey, componentProvider) does nothing more exotic than runnables[appKey] = { run: (...) => ... }. Your index.js (or Expo's AppEntry.js) is the last thing in the JS bundle's module graph — every import above it is fully evaluated first, synchronously, in dependency order. If any of those imports throws — a native module accessed before it's linked, a bad top-level require, a circular import resolving to undefined, a Reanimated worklet-plugin misconfiguration corrupting the Babel output — Metro's module system propagates that exception up through the bundle's IIFE, and the line calling registerComponent is never reached. runnables stays empty.

On the native side: iOS's RCTAppDelegate/AppDelegate.swift builds an RCTBridge (or, bridgeless, an RCTHost) with a moduleName, and Android's MainActivity.getMainComponentName() feeds the same value to ReactActivityDelegate. Once the bundle finishes loading, native calls AppRegistry.runApplication(moduleName, initialProps), which does invariant(runnables[appKey], '"%s" has not been registered...') — the exact check that fires. Two failure families, and diagnosis is choosing between them:

  1. The JS graph threw before reaching registerComponent. runnables was never populated at all, for any key.
  2. runnables has an entry, just not under the key native is asking for. A pure naming mismatch between JS appKey and the native moduleName constant — nothing threw, the wrong door is being knocked on.

Module AppRegistry is not a registered callable module (calling runApplication) sits a layer below: it fires when the native module system has no record of AppRegistry at all, because the JS bundle failed to load or parse entirely — a syntax error, a corrupted download, or Metro serving a 500 in place of a bundle. Seeing that wording means check Metro's terminal output or the bundle-fetch network response first; the bundle itself is broken or missing, not just your registration call.

Evidence worth pulling before you guess:

bash

npx react-native info

bash

# Android: watch the actual native-side exception and moduleName it requested
adb logcat *:S ReactNative:V ReactNativeJS:V

bash

# See exactly what Metro served and whether it 200'd
curl -s -o bundle.js -w "%{http_code}\n" \
  "http://localhost:8081/index.bundle?platform=android&dev=true"

6. The Fix

Fix 1 — find and fix the throw that pre-empts registration (the real fix, most of the time).

diff

 // index.js
 import { AppRegistry } from 'react-native';
 import App from './App';
 import { name as appName } from './app.json';
-import { NativeModules } from 'react-native';
-NativeModules.SomeNotLinkedModule.doThing();
+// Guard any native-module access that runs at import time —
+// call it inside a component/effect, after the app has mounted,
+// not at module scope.

 AppRegistry.registerComponent(appName, () => App);

Read the first red error, not the invariant — Metro's terminal and the device log both show the original TypeError/ReferenceError a moment earlier. Fixing that root exception is the real fix; the invariant is just the symptom of registerComponent never running.

Fix 2 — align the appKey across all three places (config-mismatch case).

diff

 // index.js
-AppRegistry.registerComponent('MyApp', () => App);
+AppRegistry.registerComponent('Repro', () => App);

kotlin

// android/app/src/main/java/com/repro/MainActivity.kt
class MainActivity : ReactActivity() {
  override fun getMainComponentName(): String = "Repro" // must match index.js exactly, case-sensitive
}

swift

// ios/Repro/AppDelegate.swift
override func application(
  _ application: UIApplication,
  didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
  factory.startReactNative(
    withModuleName: "Repro", // must match index.js exactly
    in: window,
    launchOptions: launchOptions
  )
  return true
}

Fix 3 — wrong or stale Metro (dev only).

bash

# Confirm which project Metro is actually serving from
lsof -i :8081

# If it's the wrong one, kill it and restart from the right folder
watchman watch-del-all
npx react-native start --reset-cache

Fix 4 — full clean rebuild, genuinely warranted when a native rename, a native-module link, or a Reanimated/Codegen change is involved, not as a ritual first step:

bash

watchman watch-del-all
rm -rf node_modules && npm install
npx react-native start --reset-cache
cd ios && rm -rf Pods Podfile.lock build && pod install --repo-update && cd ..
cd android && ./gradlew clean && cd ..
rm -rf ~/Library/Developer/Xcode/DerivedData/*

Each step earns its place: watchman watch-del-all clears file-watch state that can make Metro serve a graph frozen before your latest edit; --reset-cache clears Metro's transform cache, which otherwise keeps serving the pre-fix bundle; the iOS Pods wipe matters when a native module was added/removed, since a stale Pods directory can leave a dangling moduleName reference; gradlew clean forces Android to relink instead of reusing a cached MainActivity class from before your rename.

What only defers it: force-quitting and reopening the app (you'll re-hit the same module-init throw); --reset-cache alone when the real bug is a genuine exception, not a cache issue; wrapping the offending native call in a try/catch that swallows the error — registerComponent now runs, but whatever that call was supposed to provide is silently missing, moving the crash later and making it harder to trace.

7. Best Practices & The Better Design

Don't do work with side effects — especially native-module access — at module scope. Do it inside components, hooks, or explicit initialization functions that run after AppRegistry.registerComponent has already succeeded, so a failure there is an in-app error your error boundary can catch instead of a boot-time crash nothing can catch:

tsx

// App.tsx
import React, { useEffect, useState } from 'react';
import { View, Text } from 'react-native';
import { someNativeModule } from './native/someNativeModule';

export default function App() {
  const [ready, setReady] = useState(false);
  const [initError, setInitError] = useState<Error | null>(null);

  useEffect(() => {
    let cancelled = false;
    someNativeModule
      .init()
      .then(() => !cancelled && setReady(true))
      .catch((e: Error) => !cancelled && setInitError(e));
    return () => {
      cancelled = true;
    };
  }, []);

  if (initError) {
    return (
      <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
        <Text>Startup failed: {initError.message}</Text>
      </View>
    );
  }
  if (!ready) return null; // or a splash/loading view

  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
      <Text>Ready</Text>
    </View>
  );
}

tsx

// index.js — stays trivial and side-effect-free by design
import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';

AppRegistry.registerComponent(appName, () => App);

Beyond that: keep one source of truth for the app name instead of hardcoding it a second time by hand in native files; on Expo, prefer CNG (app.config.ts + npx expo prebuild) so name/moduleName wiring across android//ios/ is regenerated rather than hand-edited and left to drift.

8. How to Prevent It Long-Term

Add npx react-native doctor (or npx expo-doctor) to CI so a broken toolchain or mismatched native config fails the build before a device ever sees it. Run a CI job that does an actual assembleRelease/archive build on both platforms, not just debug — the release-only white-screen variant hides behind Metro's dev-mode forgiveness (LogBox often recovers from a throw on Fast Refresh; a release Hermes bundle does not). Wire ErrorUtils.setGlobalHandler (or a Sentry/Crashlytics native SDK) so a module-init throw reports a symbolicated stack instead of a bare invariant — upload Hermes source maps on every release build. Lint against side-effecting top-level code with a no-restricted-syntax rule targeting calls at module scope outside index.js's two registration lines. Treat any project rename as a three-file change — app.json/package.json, getMainComponentName(), the iOS moduleName — and grep for the old string across android/ and ios/ before calling it done.

9. Key Takeaways

  • Invariant Violation: "X" has not been registered means AppRegistry.runnables has no entry for the key native asked for — either nothing registered at all (a JS throw pre-empted it) or something registered under a different key (a naming mismatch).
  • Read the error above the invariant first — Metro and the device log usually show the real exception a moment earlier; the invariant is the symptom, not the cause.
  • Module AppRegistry is not a registered callable module (calling runApplication) is one layer lower: the JS bundle itself failed to load or parse, so check Metro's bundle response before touching your registration code.
  • This error's wording hasn't moved across RN 0.79–0.87 or Legacy/New Architecture — what changes release to release is which native-module or deep-import failures can now throw at module-init time instead of later.
  • Never do native-module or async work at module scope in index.js/App.tsx's top level; push it into an effect behind an error boundary so a startup failure is a screen you control, not a boot crash nothing can catch.

Related: this same "module failed before it could register" class of failure also produces TurboModuleRegistry.getEnforcing(...) could not be found when the missing piece is a specific native module rather than the whole app, and Unimplemented component: <X> when it's a specific Fabric view — see those articles for the native-module-not-linked and view-not-registered variants of the same underlying mismatch.

react-nativereact-native-errorsappregistryexpometrohermesnew-architecturetroubleshooting

From aspiring developer to blogger, I test learning platforms, simplify programming syntax, and share resources that work. Helping you code smarter as I grow myself. New or experienced, you're welcome here.

Comments