Expo Modules API: Panduan Membuat Modul Native Custom dengan Swift dan Kotlin

Panduan praktis membuat modul native custom pakai Expo Modules API di React Native. Dari setup, implementasi Swift & Kotlin, event system, type-safe Records, sampai integrasi native view dengan SwiftUI dan Jetpack Compose di Expo SDK 55.

Pendahuluan: Mengapa Anda Perlu Menulis Kode Native?

React Native memang luar biasa — Anda bisa membangun aplikasi mobile pakai JavaScript dan React. Tapi jujur, ada momen di mana JavaScript saja nggak cukup. Mungkin Anda butuh akses sensor perangkat yang belum ada library-nya, atau harus integrasi SDK pihak ketiga yang cuma tersedia dalam bentuk native. Kadang juga soal performa — ada operasi yang memang perlu dijalankan langsung di level native.

Nah, di sinilah Expo Modules API jadi penyelamat.

Dengan Expo Modules API, Anda bisa menulis kode Swift (iOS) dan Kotlin (Android) langsung di dalam project Expo — tanpa boilerplate berlebihan, tanpa konfigurasi ribet, dan dengan type-safety otomatis di kedua platform. Kalau Anda pernah coba bikin native module pakai cara lama, Anda pasti tahu betapa melegakannya ini.

Dalam panduan ini, kita akan bahas cara membuat modul native custom pakai Expo Modules API — dari konsep dasar, setup project, sampai implementasi modul yang siap produksi dengan event system, async functions, dan integrasi native view. Semua contoh di sini menggunakan Expo SDK 55 dan React Native 0.83+, versi terbaru di tahun 2026.

Apa Itu Expo Modules API?

Singkatnya, Expo Modules API adalah sistem yang memungkinkan Anda menulis modul native pakai Swift dan Kotlin untuk menambah kemampuan baru ke aplikasi React Native. API ini dirancang dengan prinsip: manfaatkan fitur modern dari kedua bahasa, konsisten di kedua platform, boilerplate minimal, dan performa setara Turbo Modules bawaan React Native.

Keunggulan Expo Modules API

  • Satu API untuk dua platform — Definisi modul yang konsisten untuk iOS dan Android. Satu developer bisa memelihara modul di kedua platform tanpa harus jadi expert di keduanya.
  • Type-safety bawaan — Konversi tipe otomatis dari JavaScript ke tipe native (String, Int, Record, dll.) tanpa parsing manual. Ini mengurangi bug yang bikin frustrasi.
  • Mendukung New Architecture — Semua Expo Modules otomatis kompatibel dengan New Architecture (JSI, Fabric) dan tetap backward-compatible dengan arsitektur lama.
  • Minimal boilerplate — Dibandingkan Turbo Modules vanilla, Anda menulis jauh lebih sedikit kode setup. Serius, perbedaannya cukup signifikan.
  • Performa tinggi — Memanfaatkan JSI (JavaScript Interface) untuk komunikasi langsung, bukan bridge JSON yang lambat. Mampu mengeksekusi ratusan ribu native method calls per detik.
  • Local module support — Buat modul langsung di dalam project tanpa perlu publish ke npm.

Expo Modules API vs Turbo Modules: Kapan Menggunakan yang Mana?

Pertanyaan ini sering banget muncul di kalangan developer React Native. Ini panduan singkatnya:

  • Gunakan Expo Modules API jika Anda menginginkan developer experience terbaik, menulis Swift/Kotlin, dan tidak keberatan bergantung pada package expo. Buat sebagian besar developer, ini pilihan paling tepat.
  • Gunakan Turbo Modules jika Anda memang butuh menulis kode C++ langsung — misalnya untuk akses mekanisme level sangat rendah.

Dari segi performa, keduanya sebanding. Sama-sama pakai JSI dan mampu melakukan ratusan ribu panggilan native per detik. Jadi ini bukan soal kecepatan, tapi soal ergonomi development.

Persiapan dan Setup Project

Prasyarat

Sebelum mulai, pastikan Anda sudah punya ini semua:

  • Node.js versi 18 atau lebih baru
  • Expo CLI terbaru (npx expo)
  • Untuk iOS: macOS dengan Xcode 16+ dan CocoaPods
  • Untuk Android: Android Studio dengan Android SDK terbaru
  • Project Expo yang sudah berjalan (minimal SDK 52, direkomendasikan SDK 55)

Membuat Local Module Baru

Ada dua jenis modul yang bisa Anda buat:

  • Local module — untuk digunakan di satu project saja, dibuat langsung di dalam folder modules/
  • Standalone module — untuk digunakan di beberapa project, bisa dipublish ke npm

Untuk panduan ini, kita fokus ke local module karena lebih praktis untuk kebanyakan use case. Jalankan perintah berikut di root project Anda:

npx create-expo-module@latest my-device-info --local

Perintah ini akan membuat struktur folder berikut di dalam project Anda:

modules/
└── my-device-info/
    ├── index.ts                    # Entry point JavaScript
    ├── src/
    │   └── MyDeviceInfoModule.ts   # TypeScript module wrapper
    ├── expo-module.config.json     # Konfigurasi modul
    ├── android/
    │   └── src/main/java/expo/modules/mydeviceinfo/
    │       └── MyDeviceInfoModule.kt   # Implementasi Kotlin
    └── ios/
        └── MyDeviceInfoModule.swift    # Implementasi Swift

Bagian yang paling menyenangkan? Modul lokal ini secara otomatis di-link ke aplikasi Anda. Tidak perlu konfigurasi tambahan di package.json atau app.json. Langsung jalan.

Memahami expo-module.config.json

File konfigurasi ini memberi tahu Expo cara menemukan dan memuat modul Anda:

{
  "platforms": ["ios", "android"],
  "ios": {
    "modules": ["MyDeviceInfoModule"]
  },
  "android": {
    "modules": ["expo.modules.mydeviceinfo.MyDeviceInfoModule"]
  }
}

Membuat Modul Native Pertama Anda: Device Info

Oke, saatnya ngoding. Mari kita buat modul yang mengambil informasi perangkat — nama device, versi OS, level baterai, dan total memori. Ini contoh yang cukup realistis dan sering dibutuhkan di aplikasi produksi.

Implementasi iOS (Swift)

Buka file modules/my-device-info/ios/MyDeviceInfoModule.swift dan tulis implementasi berikut:

import ExpoModulesCore
import UIKit

public class MyDeviceInfoModule: Module {
  public func definition() -> ModuleDefinition {
    Name("MyDeviceInfo")

    // Fungsi sinkron untuk mendapatkan nama perangkat
    Function("getDeviceName") { () -> String in
      return UIDevice.current.name
    }

    // Fungsi sinkron untuk mendapatkan versi OS
    Function("getOSVersion") { () -> String in
      return UIDevice.current.systemVersion
    }

    // Fungsi sinkron untuk mendapatkan model perangkat
    Function("getDeviceModel") { () -> String in
      var systemInfo = utsname()
      uname(&systemInfo)
      let modelCode = withUnsafePointer(to: &systemInfo.machine) {
        $0.withMemoryRebound(to: CChar.self, capacity: 1) {
          String(validatingUTF8: $0)
        }
      }
      return modelCode ?? UIDevice.current.model
    }

    // Fungsi async untuk mendapatkan level baterai
    AsyncFunction("getBatteryLevel") { () -> Float in
      UIDevice.current.isBatteryMonitoringEnabled = true
      return UIDevice.current.batteryLevel
    }

    // Fungsi untuk mendapatkan info lengkap sebagai Record
    Function("getFullDeviceInfo") { () -> [String: Any] in
      UIDevice.current.isBatteryMonitoringEnabled = true
      return [
        "deviceName": UIDevice.current.name,
        "osVersion": UIDevice.current.systemVersion,
        "platform": "ios",
        "batteryLevel": UIDevice.current.batteryLevel,
        "totalMemory": ProcessInfo.processInfo.physicalMemory
      ]
    }
  }
}

Implementasi Android (Kotlin)

Sekarang buka file modules/my-device-info/android/src/main/java/expo/modules/mydeviceinfo/MyDeviceInfoModule.kt:

package expo.modules.mydeviceinfo

import android.os.BatteryManager
import android.os.Build
import android.app.ActivityManager
import android.content.Context
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class MyDeviceInfoModule : Module() {
  override fun definition() = ModuleDefinition {
    Name("MyDeviceInfo")

    // Fungsi sinkron untuk mendapatkan nama perangkat
    Function("getDeviceName") {
      Build.MODEL
    }

    // Fungsi sinkron untuk mendapatkan versi OS
    Function("getOSVersion") {
      Build.VERSION.RELEASE
    }

    // Fungsi sinkron untuk mendapatkan model perangkat
    Function("getDeviceModel") {
      "${Build.MANUFACTURER} ${Build.MODEL}"
    }

    // Fungsi async untuk mendapatkan level baterai
    AsyncFunction("getBatteryLevel") {
      val batteryManager = appContext.reactContext
        ?.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
      val level = batteryManager
        ?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) ?: -1
      level / 100f
    }

    // Fungsi untuk mendapatkan info lengkap
    Function("getFullDeviceInfo") {
      val activityManager = appContext.reactContext
        ?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
      val memoryInfo = ActivityManager.MemoryInfo()
      activityManager?.getMemoryInfo(memoryInfo)

      mapOf(
        "deviceName" to Build.MODEL,
        "osVersion" to Build.VERSION.RELEASE,
        "platform" to "android",
        "batteryLevel" to run {
          val bm = appContext.reactContext
            ?.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
          (bm?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) ?: -1) / 100f
        },
        "totalMemory" to memoryInfo.totalMem
      )
    }
  }
}

Perhatikan betapa miripnya struktur kode di Swift dan Kotlin. Itu salah satu hal yang menurut saya paling keren dari Expo Modules API — pola yang konsisten di kedua platform.

TypeScript Wrapper

Buka file modules/my-device-info/src/MyDeviceInfoModule.ts dan buat wrapper yang type-safe:

import { requireNativeModule } from "expo-modules-core";

// Definisikan tipe untuk info perangkat
export interface DeviceInfo {
  deviceName: string;
  osVersion: string;
  platform: "ios" | "android";
  batteryLevel: number;
  totalMemory: number;
}

// Ambil modul native
const MyDeviceInfo = requireNativeModule("MyDeviceInfo");

export function getDeviceName(): string {
  return MyDeviceInfo.getDeviceName();
}

export function getOSVersion(): string {
  return MyDeviceInfo.getOSVersion();
}

export function getDeviceModel(): string {
  return MyDeviceInfo.getDeviceModel();
}

export async function getBatteryLevel(): Promise<number> {
  return await MyDeviceInfo.getBatteryLevel();
}

export function getFullDeviceInfo(): DeviceInfo {
  return MyDeviceInfo.getFullDeviceInfo();
}

Menggunakan Modul di Komponen React Native

Sekarang bagian yang paling ditunggu — menggunakan modul native di komponen React Native:

import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
import { useState, useEffect } from "react";
import {
  getDeviceName,
  getOSVersion,
  getDeviceModel,
  getBatteryLevel,
  getFullDeviceInfo,
} from "../modules/my-device-info";

export default function DeviceInfoScreen() {
  const [batteryLevel, setBatteryLevel] = useState<number | null>(null);
  const deviceInfo = getFullDeviceInfo();

  useEffect(() => {
    getBatteryLevel().then(setBatteryLevel);
  }, []);

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Informasi Perangkat</Text>

      <View style={styles.card}>
        <InfoRow label="Nama" value={deviceInfo.deviceName} />
        <InfoRow label="Model" value={getDeviceModel()} />
        <InfoRow label="Platform" value={deviceInfo.platform} />
        <InfoRow label="Versi OS" value={deviceInfo.osVersion} />
        <InfoRow
          label="Baterai"
          value={
            batteryLevel !== null
              ? `${Math.round(batteryLevel * 100)}%`
              : "Memuat..."
          }
        />
        <InfoRow
          label="Total RAM"
          value={`${Math.round(
            deviceInfo.totalMemory / (1024 * 1024 * 1024)
          )} GB`}
        />
      </View>

      <TouchableOpacity
        style={styles.button}
        onPress={async () => {
          const level = await getBatteryLevel();
          setBatteryLevel(level);
        }}
      >
        <Text style={styles.buttonText}>Refresh Baterai</Text>
      </TouchableOpacity>
    </View>
  );
}

function InfoRow({ label, value }: { label: string; value: string }) {
  return (
    <View style={styles.row}>
      <Text style={styles.label}>{label}</Text>
      <Text style={styles.value}>{value}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20, backgroundColor: "#f5f5f5" },
  title: { fontSize: 24, fontWeight: "bold", marginBottom: 20 },
  card: {
    backgroundColor: "#fff",
    borderRadius: 12,
    padding: 16,
    shadowColor: "#000",
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 8,
    elevation: 3,
  },
  row: {
    flexDirection: "row",
    justifyContent: "space-between",
    paddingVertical: 12,
    borderBottomWidth: 1,
    borderBottomColor: "#f0f0f0",
  },
  label: { fontSize: 16, color: "#666" },
  value: { fontSize: 16, fontWeight: "600", color: "#333" },
  button: {
    backgroundColor: "#007AFF",
    padding: 16,
    borderRadius: 12,
    alignItems: "center",
    marginTop: 20,
  },
  buttonText: { color: "#fff", fontSize: 16, fontWeight: "600" },
});

Membangun dan Menjalankan

Satu hal penting — karena modul native butuh kode native yang dikompilasi, Anda harus pakai development build, bukan Expo Go:

# Buat development build
npx expo prebuild
npx expo run:ios
# atau
npx expo run:android

# Atau gunakan EAS Build
eas build --profile development --platform ios

Setelah build selesai, modul native Anda langsung tersedia. Semudah itu.

Fitur Lanjutan: Event System

Salah satu kemampuan yang menurut saya paling powerful dari modul native adalah mengirim event dari kode native ke JavaScript secara real-time. Ini berguna banget untuk memantau perubahan yang terjadi di perangkat — seperti perubahan koneksi jaringan, orientasi layar, atau update sensor.

Implementasi Event di Swift (iOS)

import ExpoModulesCore
import UIKit

public class MyDeviceEventsModule: Module {
  public func definition() -> ModuleDefinition {
    Name("MyDeviceEvents")

    // Deklarasikan event yang akan dikirim
    Events("onBatteryChange", "onOrientationChange")

    // Dipanggil saat listener pertama ditambahkan
    OnStartObserving {
      UIDevice.current.isBatteryMonitoringEnabled = true

      NotificationCenter.default.addObserver(
        forName: UIDevice.batteryLevelDidChangeNotification,
        object: nil,
        queue: .main
      ) { [weak self] _ in
        self?.sendEvent("onBatteryChange", [
          "level": UIDevice.current.batteryLevel,
          "state": self?.batteryStateString() ?? "unknown"
        ])
      }

      UIDevice.current.beginGeneratingDeviceOrientationNotifications()
      NotificationCenter.default.addObserver(
        forName: UIDevice.orientationDidChangeNotification,
        object: nil,
        queue: .main
      ) { [weak self] _ in
        self?.sendEvent("onOrientationChange", [
          "orientation": self?.orientationString() ?? "unknown"
        ])
      }
    }

    // Dipanggil saat listener terakhir dihapus
    OnDestroy {
      NotificationCenter.default.removeObserver(self)
      UIDevice.current.endGeneratingDeviceOrientationNotifications()
    }
  }

  private func batteryStateString() -> String {
    switch UIDevice.current.batteryState {
    case .charging: return "charging"
    case .full: return "full"
    case .unplugged: return "unplugged"
    default: return "unknown"
    }
  }

  private func orientationString() -> String {
    switch UIDevice.current.orientation {
    case .portrait: return "portrait"
    case .landscapeLeft, .landscapeRight: return "landscape"
    default: return "unknown"
    }
  }
}

Implementasi Event di Kotlin (Android)

package expo.modules.mydeviceevents

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class MyDeviceEventsModule : Module() {
  private var batteryReceiver: BroadcastReceiver? = null

  override fun definition() = ModuleDefinition {
    Name("MyDeviceEvents")

    Events("onBatteryChange", "onOrientationChange")

    OnStartObserving {
      batteryReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
          val level = intent?.getIntExtra(
            BatteryManager.EXTRA_LEVEL, -1
          ) ?: -1
          val scale = intent?.getIntExtra(
            BatteryManager.EXTRA_SCALE, 100
          ) ?: 100
          val status = intent?.getIntExtra(
            BatteryManager.EXTRA_STATUS, -1
          ) ?: -1

          sendEvent("onBatteryChange", mapOf(
            "level" to (level.toFloat() / scale.toFloat()),
            "state" to when (status) {
              BatteryManager.BATTERY_STATUS_CHARGING -> "charging"
              BatteryManager.BATTERY_STATUS_FULL -> "full"
              BatteryManager.BATTERY_STATUS_DISCHARGING -> "unplugged"
              else -> "unknown"
            }
          ))
        }
      }

      appContext.reactContext?.registerReceiver(
        batteryReceiver,
        IntentFilter(Intent.ACTION_BATTERY_CHANGED)
      )
    }

    OnDestroy {
      batteryReceiver?.let {
        appContext.reactContext?.unregisterReceiver(it)
      }
      batteryReceiver = null
    }
  }
}

Menggunakan Event di JavaScript

Di sisi JavaScript, kita bisa bikin custom hook yang rapi untuk subscribe ke event native:

import { useEffect, useState } from "react";
import { EventEmitter } from "expo-modules-core";
import MyDeviceEventsModule from "../modules/my-device-events";

const emitter = new EventEmitter(MyDeviceEventsModule);

interface BatteryEvent {
  level: number;
  state: "charging" | "full" | "unplugged" | "unknown";
}

export function useBatteryMonitor() {
  const [battery, setBattery] = useState<BatteryEvent | null>(null);

  useEffect(() => {
    const subscription = emitter.addListener(
      "onBatteryChange",
      (event: BatteryEvent) => {
        setBattery(event);
      }
    );

    return () => subscription.remove();
  }, []);

  return battery;
}

// Penggunaan di komponen
function BatteryWidget() {
  const battery = useBatteryMonitor();

  return (
    <View>
      <Text>
        Baterai: {battery ? `${Math.round(battery.level * 100)}%` : "..."}
      </Text>
      <Text>Status: {battery?.state ?? "Mendeteksi..."}</Text>
    </View>
  );
}

Clean, kan? Pattern ini bisa dipakai ulang untuk event native lainnya.

Type Safety dengan Records

Expo Modules API punya sistem Record yang memungkinkan Anda mendefinisikan struktur data dengan type-safety penuh di level native. Ini jauh lebih aman (dan lebih nyaman) daripada pakai dictionary atau map biasa — karena kalau tipe datanya salah, error-nya langsung jelas, bukan silent failure yang bikin debugging jadi mimpi buruk.

Definisi Record di Swift

import ExpoModulesCore

// Definisikan Record dengan tipe yang jelas
struct UserPreferences: Record {
  @Field var theme: String = "system"
  @Field var fontSize: Int = 16
  @Field var notificationsEnabled: Bool = true
  @Field var language: String = "id"
}

public class PreferencesModule: Module {
  public func definition() -> ModuleDefinition {
    Name("Preferences")

    // Menerima Record sebagai parameter
    Function("savePreferences") { (prefs: UserPreferences) in
      let defaults = UserDefaults.standard
      defaults.set(prefs.theme, forKey: "theme")
      defaults.set(prefs.fontSize, forKey: "fontSize")
      defaults.set(prefs.notificationsEnabled, forKey: "notifications")
      defaults.set(prefs.language, forKey: "language")
    }

    // Mengembalikan Record
    Function("loadPreferences") { () -> UserPreferences in
      let defaults = UserDefaults.standard
      var prefs = UserPreferences()
      prefs.theme = defaults.string(forKey: "theme") ?? "system"
      prefs.fontSize = defaults.integer(forKey: "fontSize")
      prefs.notificationsEnabled = defaults.bool(forKey: "notifications")
      prefs.language = defaults.string(forKey: "language") ?? "id"
      return prefs
    }
  }
}

Definisi Record di Kotlin

package expo.modules.preferences

import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record

class UserPreferences : Record {
  @Field var theme: String = "system"
  @Field var fontSize: Int = 16
  @Field var notificationsEnabled: Boolean = true
  @Field var language: String = "id"
}

class PreferencesModule : Module() {
  override fun definition() = ModuleDefinition {
    Name("Preferences")

    Function("savePreferences") { prefs: UserPreferences ->
      val sharedPrefs = appContext.reactContext
        ?.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
      sharedPrefs?.edit()?.apply {
        putString("theme", prefs.theme)
        putInt("fontSize", prefs.fontSize)
        putBoolean("notifications", prefs.notificationsEnabled)
        putString("language", prefs.language)
        apply()
      }
    }

    Function("loadPreferences") {
      val sharedPrefs = appContext.reactContext
        ?.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
      UserPreferences().apply {
        theme = sharedPrefs?.getString("theme", "system") ?: "system"
        fontSize = sharedPrefs?.getInt("fontSize", 16) ?: 16
        notificationsEnabled = sharedPrefs
          ?.getBoolean("notifications", true) ?: true
        language = sharedPrefs?.getString("language", "id") ?: "id"
      }
    }
  }
}

Dengan Record, jika JavaScript mengirim data dengan tipe yang salah (misalnya string untuk field bertipe Int), Expo Modules API langsung menolaknya dengan error yang jelas. Nggak ada lagi guessing game soal kenapa data Anda corrupt.

Membungkus Library Native Pihak Ketiga

Ini salah satu use case yang paling sering saya temui di lapangan. Anda punya library native yang bagus — mungkin library enkripsi, SDK analytics, atau library image processing — tapi cuma tersedia untuk iOS dan Android. Expo Modules API bikin proses wrapping-nya jadi jauh lebih mudah.

Langkah-Langkah Umum

  1. Buat modul lokal menggunakan npx create-expo-module@latest --local
  2. Tambahkan dependency native — di iOS melalui Podspec, di Android melalui build.gradle
  3. Tulis wrapper yang mengekspos API library ke JavaScript melalui Expo Modules API
  4. Buat TypeScript wrapper dengan tipe yang lengkap

Contoh: Menambahkan Dependency Native

Untuk iOS, tambahkan dependency di Podspec modul Anda:

# modules/my-crypto/ios/MyCrypto.podspec
Pod::Spec.new do |s|
  s.name           = "MyCrypto"
  s.version        = "1.0.0"
  s.summary        = "Native encryption module"
  s.homepage       = "https://example.com"
  s.license        = "MIT"
  s.author         = "Your Name"
  s.source         = { git: "" }
  s.platform       = :ios, "15.1"
  s.swift_version  = "5.4"

  s.dependency "ExpoModulesCore"
  s.dependency "CryptoSwift", "~> 1.8"  # Library pihak ketiga

  s.source_files = "**/*.swift"
end

Untuk Android, tambahkan di build.gradle:

// modules/my-crypto/android/build.gradle
dependencies {
    implementation project(":expo-modules-core")
    implementation "org.bouncycastle:bcprov-jdk15on:1.70"
}

Setelah menambahkan dependency, jalankan npx pod-install untuk iOS dan rebuild project Anda. Prosesnya straightforward — yang penting Podspec dan build.gradle-nya benar.

Config Plugins: Kustomisasi Native Build

Config plugins memungkinkan Anda memodifikasi konfigurasi native project yang dihasilkan oleh npx expo prebuild. Ini berguna banget untuk menambahkan permissions, mengubah Info.plist, atau memodifikasi AndroidManifest.xml tanpa harus edit file native secara langsung.

// modules/my-device-info/app.plugin.js
const {
  withInfoPlist,
  withAndroidManifest,
} = require("expo/config-plugins");

module.exports = function withMyDeviceInfo(config) {
  // Modifikasi Info.plist untuk iOS
  config = withInfoPlist(config, (config) => {
    config.modResults.NSLocationWhenInUseUsageDescription =
      "Aplikasi membutuhkan lokasi untuk fitur device info";
    return config;
  });

  // Modifikasi AndroidManifest.xml untuk Android
  config = withAndroidManifest(config, (config) => {
    const mainApp =
      config.modResults.manifest.application?.[0];
    if (mainApp) {
      mainApp["meta-data"] = mainApp["meta-data"] || [];
      mainApp["meta-data"].push({
        $: {
          "android:name": "com.myapp.DEVICE_INFO_ENABLED",
          "android:value": "true",
        },
      });
    }
    return config;
  });

  return config;
};

Kemudian daftarkan plugin di app.json:

{
  "expo": {
    "plugins": [
      "./modules/my-device-info/app.plugin.js"
    ]
  }
}

Best Practices dan Tips Produksi

Setelah bereksperimen dan deploy beberapa modul native ke produksi, ada beberapa pelajaran yang menurut saya worth sharing.

1. Error Handling yang Proper

Jangan pernah biarkan error native meledak tanpa pesan yang jelas. Selalu tangani error di level native dan berikan pesan yang bermakna ke JavaScript:

// Swift
import ExpoModulesCore

public class SafeModule: Module {
  public func definition() -> ModuleDefinition {
    Name("SafeModule")

    AsyncFunction("riskyOperation") { (input: String) -> String in
      guard !input.isEmpty else {
        throw Exception(
          name: "ERR_INVALID_INPUT",
          description: "Input tidak boleh kosong"
        )
      }

      guard let result = performNativeOperation(input) else {
        throw Exception(
          name: "ERR_OPERATION_FAILED",
          description: "Operasi native gagal untuk input: \(input)"
        )
      }

      return result
    }
  }
}
// Kotlin
AsyncFunction("riskyOperation") { input: String ->
  if (input.isEmpty()) {
    throw CodedException(
      "ERR_INVALID_INPUT",
      "Input tidak boleh kosong",
      null
    )
  }

  val result = performNativeOperation(input)
    ?: throw CodedException(
      "ERR_OPERATION_FAILED",
      "Operasi native gagal untuk input: $input",
      null
    )

  result
}

2. Testing Modul Native

Uji modul native Anda secara terpisah di setiap platform. Ini memang agak lebih ribet daripada testing JavaScript biasa, tapi percayalah — ini bakal menyelamatkan Anda dari banyak sakit kepala di kemudian hari:

// __tests__/MyDeviceInfo.test.ts
import {
  getDeviceName,
  getOSVersion,
  getBatteryLevel,
  getFullDeviceInfo,
} from "../modules/my-device-info";

describe("MyDeviceInfo Module", () => {
  it("should return device name as string", () => {
    const name = getDeviceName();
    expect(typeof name).toBe("string");
    expect(name.length).toBeGreaterThan(0);
  });

  it("should return OS version", () => {
    const version = getOSVersion();
    expect(version).toMatch(/^\d+/);
  });

  it("should return battery level between 0 and 1", async () => {
    const level = await getBatteryLevel();
    expect(level).toBeGreaterThanOrEqual(0);
    expect(level).toBeLessThanOrEqual(1);
  });

  it("should return full device info with all fields", () => {
    const info = getFullDeviceInfo();
    expect(info).toHaveProperty("deviceName");
    expect(info).toHaveProperty("osVersion");
    expect(info).toHaveProperty("platform");
    expect(["ios", "android"]).toContain(info.platform);
  });
});

3. Performa dan Memori

  • Gunakan Function untuk operasi cepat — fungsi sinkron yang dieksekusi langsung tanpa overhead async. Cocok untuk getter sederhana.
  • Gunakan AsyncFunction untuk operasi berat — operasi I/O, network, atau komputasi intensif agar tidak memblokir UI thread.
  • Bersihkan resource — selalu implementasi OnDestroy untuk menghapus listener, menutup koneksi, dan membebaskan memori. Memory leak di native itu lebih berbahaya daripada di JavaScript.
  • Hindari mengirim data besar melalui bridge secara berulang. Untuk data besar, pertimbangkan shared memory atau file temporary.

4. Struktur Project yang Rapi

modules/
├── my-device-info/          # Modul informasi perangkat
├── my-crypto/               # Modul enkripsi
├── my-biometrics/           # Modul biometrik
└── my-file-system/          # Modul file system custom

Pisahkan setiap fitur native ke modul yang terpisah. Ini bikin kode lebih mudah di-maintain dan di-test. Plus, Anda bisa mengaktifkan atau menonaktifkan fitur tertentu tanpa mengganggu yang lain.

Expo UI: Native View dengan SwiftUI dan Jetpack Compose

Selain modul native, Expo SDK 55 juga memperkenalkan Expo UI — dan jujur, ini fitur yang bikin saya excited. Anda bisa menggunakan komponen SwiftUI dan Jetpack Compose langsung di dalam aplikasi React Native. Artinya, komponen native asli dengan tampilan dan perilaku yang benar-benar native.

Contoh Penggunaan Expo UI

import { Host } from "@expo/ui";
import { Toggle, DatePicker, ProgressView } from "@expo/ui/swift-ui";
import { useState } from "react";
import { View, Text, StyleSheet } from "react-native";

export default function NativeUIDemo() {
  const [isDarkMode, setIsDarkMode] = useState(false);
  const [selectedDate, setSelectedDate] = useState(new Date());

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Komponen Native SwiftUI</Text>

      <Host style={styles.hostContainer}>
        <Toggle
          value={isDarkMode}
          onValueChange={setIsDarkMode}
          label="Mode Gelap"
        />

        <DatePicker
          selection={selectedDate}
          onDateChange={setSelectedDate}
          datePickerStyle="graphical"
        />

        <ProgressView value={0.7} label="Upload Progress" />
      </Host>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20 },
  title: { fontSize: 24, fontWeight: "bold", marginBottom: 20 },
  hostContainer: { gap: 16 },
});

Yang perlu ditekankan — komponen-komponen ini bukan simulasi JavaScript. Mereka adalah komponen SwiftUI dan Jetpack Compose yang sesungguhnya, dirender oleh framework native platform masing-masing. Hasilnya? Performa, aksesibilitas, dan tampilan yang identik dengan aplikasi native murni.

Debugging Modul Native

Oke, mari bicara soal hal yang kurang menyenangkan tapi penting. Debugging modul native bisa jadi tantangan karena kode berjalan di dua sisi — JavaScript dan native. Tapi dengan teknik yang tepat, prosesnya jadi jauh lebih manageable.

Debugging di iOS dengan Xcode

  1. Buka project iOS di Xcode: open ios/*.xcworkspace
  2. Pasang breakpoint di file Swift modul Anda
  3. Jalankan aplikasi dari Xcode (bukan dari terminal — ini penting)
  4. Gunakan print() atau os_log untuk logging

Debugging di Android dengan Android Studio

  1. Buka folder android/ di Android Studio
  2. Pasang breakpoint di file Kotlin modul Anda
  3. Gunakan "Attach Debugger to Android Process" untuk attach ke proses yang berjalan
  4. Gunakan Log.d("MyModule", "pesan debug") untuk logging

Tips Debugging Umum

  • Gunakan console.log di sisi JavaScript untuk memverifikasi data yang diterima dari native — kadang masalahnya cuma soal tipe data yang nggak sesuai ekspektasi
  • Periksa Metro bundler untuk error JavaScript
  • Periksa Xcode console dan Android Logcat untuk error native
  • Kalau modul tidak terdeteksi, pastikan expo-module.config.json sudah benar dan jalankan npx pod-install ulang

FAQ (Pertanyaan yang Sering Diajukan)

Apakah saya bisa menggunakan Expo Modules API tanpa Expo?

Tidak secara langsung. Expo Modules API membutuhkan package expo sebagai dependency. Tapi kabar baiknya, Anda bisa menambahkan Expo ke project React Native yang sudah ada (bare workflow) tanpa harus pakai Expo Go. Cukup install expo package dan jalankan npx install-expo-modules. Prosesnya relatif painless.

Berapa overhead performa dari Expo Modules API dibandingkan native murni?

Overhead-nya sangat minimal — praktis negligible. Expo Modules API pakai JSI yang sama dengan Turbo Modules, jadi nggak ada serialisasi JSON dari bridge lama. Kedua sistem mampu mengeksekusi ratusan ribu panggilan native per detik. Perbedaan performanya nggak terukur di skenario real-world. Jadi kalau ada yang bilang "pakai Expo lebih lambat", itu mitos yang perlu diklarifikasi.

Apakah local module bisa digunakan dengan EAS Build?

Ya, sepenuhnya kompatibel. Modul yang ada di folder modules/ otomatis dikenali dan dikompilasi saat proses build — baik development, preview, maupun production build. Nggak perlu konfigurasi tambahan.

Bagaimana cara migrasi dari Bridge Module lama ke Expo Modules API?

Prosesnya step-by-step: pertama, buat modul baru dengan npx create-expo-module@latest --local. Kemudian pindahkan logika native dari bridge module lama ke definisi modul baru pakai DSL Expo Modules (Function, AsyncFunction, Events, dll). Terakhir, update TypeScript wrapper dan ganti semua referensi di kode JavaScript Anda. Satu hal yang melegakan — Expo Modules API otomatis kompatibel dengan New Architecture dan arsitektur lama, jadi nggak perlu khawatir soal backward compatibility.

Bisakah saya mengakses Activity (Android) atau UIViewController (iOS) dari dalam modul?

Bisa. Di iOS, akses UIViewController saat ini melalui appContext.utilities?.currentViewController(). Di Android, akses Activity melalui appContext.currentActivity. Ini essential untuk modul yang butuh interaksi dengan UI system — misalnya menampilkan dialog native, share sheet, atau in-app purchases.

Tentang Penulis Editorial Team

Our team of expert writers and editors.