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 registerBundleThe 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 Repropackage.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-androidResult: 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.0app.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:
cdinto a different RN project, runnpx react-native startthere on port 8081, thenrun-androidfromRepro. The app connects to the wrong Metro, which has noReprobundle registered under that key. - AppKey mismatch after a rename: rename the app in
app.json/package.jsonbut leaveMainActivity.kt'sgetMainComponentName()(or iOSAppDelegate.swift'smoduleName) pointing at the old string — native asks for a key JS never registered. - Stale Metro cache after a JS-side rename: rename the JS
appKeyinindex.jswithout 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:
| Release | Behaviour |
|---|---|
| 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:
- The JS graph threw before reaching
registerComponent.runnableswas never populated at all, for any key. runnableshas an entry, just not under the key native is asking for. A pure naming mismatch between JSappKeyand the nativemoduleNameconstant — 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 infobash
# Android: watch the actual native-side exception and moduleName it requested
adb logcat *:S ReactNative:V ReactNativeJS:Vbash
# 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-cacheFix 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 registeredmeansAppRegistry.runnableshas 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.
CODELZ Newsletter
Join the newsletter to receive the latest updates in your inbox.