skip to content

When migrating a legacy React Native Android module built on ReactContextBaseJavaModule and @ReactMethod to a Turbo Module, what changes and what stays the same?

level: middleimportance: should knowfreq 35%

answer

  1. the base class survives underneath
  2. @ReactMethod moves into generated code
  3. spec methods become abstract overrides
  4. createNativeModules to getModule
  5. legacy packages still load via interop

basics

~20 s

The module stops extending ReactContextBaseJavaModule directly and extends the generated spec, which itself extends it and carries the @ReactMethod annotations. Registration moves from an eager ReactPackage.createNativeModules list to a lazy BaseReactPackage with getModule and a module-info map.

solid answer

~40 s

A legacy module extends `ReactContextBaseJavaModule`, implements `getName()`, and marks callable methods with `@ReactMethod` (with `isBlockingSynchronousMethod = true` for synchronous ones); a `ReactPackage` returns an instance from `createNativeModules()`, and JavaScript reads `NativeModules.StepCounter`. To migrate, you write a TypeScript spec and let Codegen generate `NativeStepCounterSpec`, which **still extends `ReactContextBaseJavaModule`**, implements `TurboModule` and `getName()`, and declares each method `abstract` with `@ReactMethod` already applied. Your class now extends that spec and overrides plain methods, so the compiler checks every signature. The package becomes a `BaseReactPackage` with `getModule` and `getReactModuleInfoProvider`, creating the module lazily instead of at startup. What stays is the constructor's `ReactApplicationContext`, `Promise` and `Callback` parameters, and lifecycle hooks. Until migrated, React Native 0.87's interop layer keeps legacy modules loading.

code

kotlin · 24 lines
kotlin
// Before: legacy module
class StepCounterModule(reactContext: ReactApplicationContext) :
    ReactContextBaseJavaModule(reactContext) {

  override fun getName() = "StepCounter"

  @ReactMethod(isBlockingSynchronousMethod = true)
  fun isAvailable(): Boolean = true

  @ReactMethod
  fun getStepsSinceBoot(promise: Promise) {
    promise.resolve(0.0)
  }
}

class StepCounterLegacyPackage : ReactPackage {
  @Deprecated("Legacy registration")
  override fun createNativeModules(reactContext: ReactApplicationContext) =
      listOf(StepCounterModule(reactContext))

  override fun createViewManagers(
      reactContext: ReactApplicationContext
  ): List<ViewManager<in Nothing, in Nothing>> = emptyList()
}

go deeper

for a junior

Know that new modules extend a generated spec instead of ReactContextBaseJavaModule directly, and that @ReactMethod and getName() are generated for you.

for a middle

Walk through the migration steps, explain eager createNativeModules versus lazy getModule, and name what the generated spec still inherits from the legacy base class.

for a senior

Plan a migration across many modules: keep JavaScript call sites stable, let the compiler find signature drift, and watch the startup gain from lazy packages.

for a principal

Weigh migrating in-house modules now against relying on the interop layer, given that the Legacy Architecture receives no new work and third-party libraries move at their own pace.

## The legacy shape Before the New Architecture, an Android native module was written by hand against a handful of conventions: - **Base class:** extend `ReactContextBaseJavaModule`, which provides the `ReactApplicationContext`. - **Name:** implement `getName()`; its value becomes `NativeModules.<name>` in JavaScript. - **Methods:** annotate each method JavaScript may call with `@ReactMethod`. Methods are asynchronous and return `void` unless marked `@ReactMethod(isBlockingSynchronousMethod = true)`. - **Results:** a trailing `Promise` or `Callback` parameter carries results back. - **Registration:** a class implementing `ReactPackage` returns instantiated modules from `createNativeModules()`. React Native's legacy docs point out that this **eagerly initializes every module when the application starts**, adding to startup time. Nothing checked the JavaScript side against the Kotlin side; a typo in a method name surfaced as `undefined is not a function` at runtime. In React Native 0.87 `ReactPackage.createNativeModules()` itself carries a `@Deprecated` annotation telling you to migrate to `BaseReactPackage` and implement `getModule`, so a legacy package now compiles with a deprecation warning. ## What the Turbo Module version looks like | Concern | Legacy | Turbo Module | |---|---|---| | Source of the method list | `@ReactMethod` annotations you write | The TypeScript spec | | Base class | `ReactContextBaseJavaModule` | Generated `NativeStepCounterSpec`, which extends `ReactContextBaseJavaModule` and implements `TurboModule` | | `@ReactMethod` | Written by you | Generated on each `abstract` method | | `getName()` | Written by you | Generated from the spec's `getEnforcing` name | | Signature checking | None | Kotlin compiler, via abstract methods | | Constants | Override `getConstants()` | Implement `getTypedExportedConstants()` when the spec declares typed constants | | Package | `ReactPackage.createNativeModules()` — eager | `BaseReactPackage.getModule()` plus `getReactModuleInfoProvider()` — lazy | | JavaScript access | `NativeModules.StepCounter` | The spec file's default export | The surprise for many candidates is the second row: the generated spec **still extends `ReactContextBaseJavaModule`**. The New Architecture did not throw that class away; it put generated, typed code on top of it. ## A migration, step by step 1. **Write the spec** mirroring the current JavaScript surface, so call sites only change their import. 2. **Build once** so the Gradle plugin runs Codegen and produces `NativeStepCounterSpec` in the package named by `codegenConfig.android.javaPackageName`. 3. **Change the superclass** from `ReactContextBaseJavaModule(reactContext)` to `NativeStepCounterSpec(reactContext)`. 4. **Turn each `@ReactMethod` function into an `override`** of the generated abstract method, dropping the annotation; fix any parameter types the compiler flags, such as numbers that are now `Double`. 5. **Delete the hand-written `getName()`**, or make it return the generated `NAME`. 6. **Replace the package**: extend `BaseReactPackage`, return the module from `getModule` for `NativeStepCounterSpec.NAME`, and advertise it in the module-info map with `isTurboModule = true`. If the old package extended `TurboReactPackage`, just rename the superclass; that class is now a deprecated alias. 7. **Update JavaScript** to import the spec instead of reading `NativeModules`. ## What happens to modules you have not migrated React Native 0.82 made the New Architecture the only one, but legacy modules still run: on Android the **Turbo Module interop** flag is on by default in 0.87, and legacy `ReactPackage` instances are still accepted. The interop code calls their `createNativeModules()` when it sets up and registers each module under its `@ReactModule` name or `getName()`. So an unmigrated module keeps working, while keeping the old eager creation and none of the type checking. ## How to verify the migration - **Build:** the Kotlin compiler confirms every spec method has an override with matching types. - **Run:** the JavaScript import resolves through `TurboModuleRegistry`, and a wrong name throws a could-not-be-found error as soon as the spec file is evaluated, instead of surfacing later as an `undefined` method. - **Profile startup:** with the module moved to a lazy `BaseReactPackage`, it should no longer be constructed before its first use. ## Traps - **Keeping `@ReactMethod` on overrides and believing it is required** — it is harmless noise; the generated abstract method already carries it. - **Leaving `createNativeModules()` in a class that now extends `BaseReactPackage`** — that base class overrides it to throw `UnsupportedOperationException`. - **Marking the migrated module `isTurboModule = false`** in the info map — the Turbo lookup then skips it. - **Assuming `getCurrentActivity()` on the module is still the API** — it is deprecated since 0.80; read `reactApplicationContext.currentActivity`.

  • Why does a legacy ReactPackage slow startup compared with a BaseReactPackage?
    `createNativeModules()` returns already-constructed modules, so every module in the package is instantiated when React Native sets up, used or not. A `BaseReactPackage` only advertises names in its module-info map and builds each module in `getModule` the first time JavaScript asks, unless its entry sets `needsEagerInit`.
  • Does the migrated module still get lifecycle hooks and the context?
    Yes. The generated spec extends `ReactContextBaseJavaModule`, so the constructor still receives a `ReactApplicationContext`, `getReactApplicationContext()` still works, and `initialize()` and `invalidate()` are still called by the Turbo Module manager. The migration changes how methods are declared and how the module is registered, not its access to Android.

saying these in an interview costs you the question

  • Turbo Modules on Android no longer extend ReactContextBaseJavaModule anywhere in their hierarchy
  • You must keep @ReactMethod on every override or JavaScript cannot call it
  • A legacy ReactPackage stops loading entirely on React Native 0.82 and later
  • createNativeModules and getModule both create modules lazily
  • TurboReactPackage is the current base class for new packages