Expo Modules API u 2026: vlastiti native modul u Swiftu i Kotlinu

Naučite kako napisati vlastite Expo native module u Swiftu i Kotlinu za React Native 2026 s primjerima za records, eventove, native views i config plugins.

Expo Modules API Vodič 2026

Ažurirano: 2. srpnja 2026.

Expo Modules API je Expov način pisanja vlastitih native modula u Swiftu i Kotlinu s minimalnim boilerplateom, punom podrškom za New Architecture i bridgeless mode te automatskom kompatibilnošću sa starom arhitekturom. Ako radiš na Expo ili bare React Native projektu u 2026. i trebaš dodati funkcionalnost koje nema u postojećim paketima (pristup HealthKitu, custom kameru, hardversku enkripciju), Expo Modules API je pragmatičan izbor koji ti štedi tjedne posla u odnosu na ručno pisanje Turbo Modula.

  • Expo Modules API u 2026. dolazi s prvorazrednom potporom za New Architecture. Moduli automatski rade u bridgeless modeu bez dodatne konfiguracije.
  • Local modules (unutar modules/ direktorija) i novi inline modules (Swift/Kotlin uz app kod) omogućuju iteraciju bez publish ciklusa na npm.
  • Records su konvertibilni tipovi koji daju compile-time tipsku sigurnost između JS-a i Swifta/Kotlina bez ručne validacije.
  • Config plugins su obavezni ako mijenjaš Info.plist, AndroidManifest.xml ili Podfile. Bez njih EAS Build ne zna kako regenerirati native projekte.
  • Turbo Modules biraj samo ako ti treba C++ interoperabilnost ili radiš na core-u React Nativea; za sve ostalo Expo Modules ima kraći DX.

Što je Expo Modules API i zašto ga koristiti

Expo Modules API je deklarativni DSL napisan u Swiftu (iOS) i Kotlinu (Android) koji ti dopušta da definiraš funkcije, konstante, view componente i eventove koje želiš izložiti JavaScriptu. Umjesto da ručno pišeš RCT_EXPORT_METHOD makroe, JNI potpise ili Turbo Module code-gen sheme, opišeš modul u ModuleDefinition bloku i framework napravi ostalo za tebe. Honestly, u šest React Native aplikacija koje sam otpremio, samo dvije su zahtijevale ručno napisan Turbo Module. Sve ostalo je pokrio Expo Modules API u dijelu vremena.

Ono što ga čini posebno vrijednim u 2026. je automatska podrška za New Architecture. Modul napisan po Expo Modules API-ju radi u bridgeless modeu bez dodatne konfiguracije, ali ostaje kompatibilan sa starom arhitekturom za projekte koji još nisu migrirali. Ako te zanima šira slika, pročitaj naš vodič o novoj arhitekturi React Nativea kroz JSI, Fabric i Bridgeless Mode. Expo Modules API sjeda točno na taj sloj.

Pragmatično, koristiš ga u tri glavna scenarija: (1) trebaš platform API koji nije pokriven Expo SDK-om (npr. NEHotspotConfiguration na iOS-u), (2) wrapaš postojeću native SDK biblioteku (Stripe, Braze, HealthKit) za React Native, (3) izvodiš performance-critical logiku iz JS-a u native (kriptografija, image processing, tight loops). Za sve ostalo, pogotovo za stvari koje već imaju community wrapper, nemoj pisati vlastiti modul. Održavanje košta.

Expo Modules API vs Turbo Modules: kako birati

Pitanje "Expo Modules ili Turbo Modules" je 2026. najčešća dilema na mojim code reviewima. Odgovor je kratak: ako ne pišeš C++ i ne smeta ti dependency na expo paket, uzmi Expo Modules API. Preporuka dolazi i s službene React Native dokumentacije za Turbo Modules. Turbo Modules su primarno namijenjeni ljudima koji rade na infrastrukturnoj razini React Nativea ili trebaju direktan pristup JSI i C++ layer-u.

DimenzijaExpo Modules APITurbo Modules
JeziciSwift, Kotlin (native only)Obj-C++, C++, Java/Kotlin
BoilerplateMinimalan (DSL u jednom fileu)Codegen shema + native impl + spec
New ArchitectureAutomatska iz kutijeZahtijeva codegen konfiguraciju
Kompatibilnost sa starom arhitekturomAutomatskaRučna preko interop layera
C++ pristupNe (koristi JSI wrappere)Da, puni JSI pristup
DependencyPaket expoSamo react-native
Config plugin integracijaFirst-classRučno
Krivulja učenjaBlaga (nekoliko sati)Strma (dani do tjedana)

Realan primjer iz prakse. Prošli kvartal sam napisao modul za pristup DeviceCheck API-ju na iOS-u i Play Integrity API-ju na Androidu za anti-fraud. U Expo Modules API-ju to je bilo oko 200 linija Swift/Kotlin koda; u Turbo Modulima procijenjeno je oko 500 linija s codegen shemama. Za CI/CD pipeline i EAS Build integraciju razlika je bila još veća. Expo Modules "just work" s expo prebuild, dok Turbo Modules zahtijevaju dodatne korake.

Kreiranje local modula u minutu

Najbrži put od nule do funkcionalnog modula je local module. To je Swift i Kotlin kod koji živi unutar modules/ direktorija tvoje aplikacije i automatski se povezuje bez publish-a na npm. Idealno za prototipiranje ili za module koji su previše app-specific da bi bili standalone paket.

npx create-expo-module@latest --local expo-settings

CLI će te pitati za ime modula (npr. ExpoSettings), package name i opis. Kad završi, dobiješ ovakvu strukturu:

modules/
└── expo-settings/
    ├── android/src/main/java/expo/modules/settings/
    │   └── ExpoSettingsModule.kt
    ├── ios/
    │   └── ExpoSettingsModule.swift
    ├── src/
    │   ├── index.ts
    │   └── ExpoSettings.types.ts
    ├── expo-module.config.json
    └── package.json

Za standalone modul koji ćeš objaviti na npm i koristiti u više projekata, izostavi --local zastavicu. Struktura je ista, ali dobiješ i example/ Expo app za razvoj i testiranje. Detalje o tome kako postaviti EAS pipeline za module s native kodom pokriva naš vodič o EAS Build i Submit deploy tijeku.

Novost u 2026. su inline modules. Možeš pisati Swift/Kotlin file direktno pored svog app/ koda, bez potrebe za create-expo-module scaffoldingom. Za male, jednokratne native pozive to je najmanji trošak; framework ih otkrije preko file-based konvencije.

Pisanje Swift modula za iOS

Otvori modules/expo-settings/ios/ExpoSettingsModule.swift. Ovako izgleda pun primjer modula koji perzistira temu (light/dark) u UserDefaults i šalje event kada se promijeni:

import ExpoModulesCore

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

    // Konstante dostupne iz JS-a kao ExpoSettings.PI
    Constants([
      "PI": Double.pi
    ])

    // Sync funkcija: poziv blokira JS thread
    Function("getTheme") { () -> String in
      return UserDefaults.standard.string(forKey: "theme") ?? "system"
    }

    // Async funkcija: vraća Promise u JS-u
    AsyncFunction("setTheme") { (theme: String) in
      UserDefaults.standard.set(theme, forKey: "theme")
      self.sendEvent("onChangeTheme", [
        "theme": theme
      ])
    }

    // Deklaracija eventova koje modul emitira
    Events("onChangeTheme")
  }
}

Nekoliko važnih detalja. Function je sinkrona i vraća vrijednost direktno; AsyncFunction automatski wrappa poziv u JavaScript Promise i može bacati errore koji se propagiraju kao odbijeni promisi. Signature closurea diktira JS API, a parametri i return tip se automatski konvertiraju preko JSI bindingsa bez potrebe za ručnim marshallingom.

Da bi radio testove na uređaju, otvori ios direktorij kroz Xcode s xed ios, pa pod Pods → Development Pods → ExpoSettings pronađeš Swift file za debugiranje s breakpointima. Simulator loguje print izjave direktno u Metro terminal, što je korisno za brzu iteraciju bez Xcode-a.

Pisanje Kotlin modula za Android

Android pandan istog modula živi u modules/expo-settings/android/src/main/java/expo/modules/settings/ExpoSettingsModule.kt. API je namjerno isti kao Swift verzija:

package expo.modules.settings

import android.content.Context
import androidx.core.os.bundleOf
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class ExpoSettingsModule : Module() {
  override fun definition() = ModuleDefinition {
    Name("ExpoSettings")

    Constants(
      "PI" to Math.PI
    )

    Function("getTheme") {
      val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
      prefs.getString("theme", "system") ?: "system"
    }

    AsyncFunction("setTheme") { theme: String ->
      val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
      prefs.edit().putString("theme", theme).apply()
      sendEvent("onChangeTheme", bundleOf("theme" to theme))
    }

    Events("onChangeTheme")
  }

  private val context: Context
    get() = requireNotNull(appContext.reactContext)
}

Payload eventova na Androidu je Bundle, pa koristi bundleOf helper iz androidx.core za konciznu inicijalizaciju. Ako paziš na consistency: JS API je identičan iOS verziji, što znači da tvoj index.ts ne treba znati za platform-specific detalje.

Za pristup Android Context-u koristi appContext.reactContext preko helper property-ja kao gore. Ne cacheiraj Context u instance polja, jer bi activity recreacije ostavile dangling reference. Isti princip vrijedi i za activities: dohvati appContext.currentActivity tek kad ti stvarno treba, ne u init bloku.

Records i tipska sigurnost preko mosta

Records su konvertibilni tipovi koje Expo Modules API koristi za slanje strukturiranih objekata između JS-a i native koda. Umjesto da primaš Dictionary<String, Any> i ručno validiraš polja, definiraš struct s tipovima i default vrijednostima, a framework napravi validaciju i konverziju automatski.

Swift primjer koji prima korisničku konfiguraciju:

struct AppearanceOptions: Record {
  @Field var theme: String = "system"
  @Field var accentColor: String? = nil
  @Field var reducedMotion: Bool = false
}

AsyncFunction("configure") { (options: AppearanceOptions) in
  // options.theme, options.accentColor, options.reducedMotion su tipski sigurni
  apply(options)
}

Kotlin ekvivalent koristi klasu s Field anotacijama:

class AppearanceOptions : Record {
  @Field var theme: String = "system"
  @Field var accentColor: String? = null
  @Field var reducedMotion: Boolean = false
}

Iz JS-a je poziv trivialan: ExpoSettings.configure({ theme: "dark", reducedMotion: true }). Ako pošalješ krivi tip (recimo reducedMotion: "yes" umjesto boolean-a), modul baci opisnu grešku prije nego kod dođe do tvog closurea. To spašava sate debugiranja u odnosu na "silent coerce" ponašanje starog mosta. Ja sam ovaj problem osobno pogodio u prošlom projektu s HealthKit modulom, i migracija na Records je sama otklonila cijelu klasu bugova.

Events: dvosmjerna komunikacija JS ↔ native

Za push podataka iz native koda u JS koristi eventove. Deklariraj ih u ModuleDefinition s Events("onSomething"), pa ih emitraj s sendEvent pozivom. Sa JS strane, wrap-aj ih u EventEmitter koji Expo Modules pruža:

// modules/expo-settings/src/index.ts
import { EventEmitter, NativeModulesProxy } from "expo-modules-core";
import ExpoSettings from "./ExpoSettingsModule";

const emitter = new EventEmitter(ExpoSettings);

export function addThemeListener(
  listener: (event: { theme: string }) => void
) {
  return emitter.addListener("onChangeTheme", listener);
}

export function setTheme(theme: "light" | "dark" | "system") {
  return ExpoSettings.setTheme(theme);
}

U React komponenti iskoristi ga s useEffect-om:

import { useEffect, useState } from "react";
import { addThemeListener, setTheme } from "expo-settings";

export function ThemeToggle() {
  const [theme, setLocal] = useState("system");
  useEffect(() => {
    const sub = addThemeListener(({ theme }) => setLocal(theme));
    return () => sub.remove();
  }, []);
  return (
    <Button title={`Trenutno: ${theme}`} onPress={() => setTheme("dark")} />
  );
}

Watchout: emiter je singleton per module. Ako radiš više addListener poziva u istoj sesiji, ne zaboravi remove() u cleanup funkciji. Curenje listenera je najčešći uzrok "phantom" event handlera nakon fast refresha. Meni je taj bug jednom pojeo pola dana prije nego sam ga uhvatio u profileru.

Native views: kako izložiti UIView i View

Osim funkcija, možeš izložiti i native view komponente: na iOS-u UIView, a na Androidu View podklasu. Definicija view-a ide u zaseban file (npr. ExpoSettingsView.swift) i registrira se u glavnoj ModuleDefinition preko View bloka:

View(ExpoSettingsView.self) {
  Prop("color") { (view: ExpoSettingsView, color: String) in
    view.backgroundColor = UIColor(hex: color)
  }
  Events("onLoad")
}

Sa JS strane dobiješ standardnu React komponentu koju uvezeš i koristiš kao bilo koji drugi View. Fabric renderer je zadužen za mount i measure, tako da ne moraš pisati shadow node code ručno. Ovaj pristup je pobjeda ako radiš embed native SDK komponenti (Stripe PaymentSheet, MapKit, Google Pay tipke).

Config plugins i CNG workflow

Ako tvoj modul zahtijeva izmjene u Info.plist, AndroidManifest.xml, Podfile ili build gradleu, obavezno napiši config plugin. Bez njega Expo prebuild i EAS Build će regenerirati native projekte i tvoje ručne izmjene će nestati. Ovo je najčešća greška koju vidim na code reviewima kada developeri prvi put pišu Expo module.

// modules/expo-settings/plugin/withSettings.ts
import { ConfigPlugin, withInfoPlist } from "expo/config-plugins";

const withSettings: ConfigPlugin = (config) => {
  return withInfoPlist(config, (config) => {
    config.modResults.NSFaceIDUsageDescription =
      "Trebamo Face ID za sigurnu autentifikaciju.";
    return config;
  });
};

export default withSettings;

Zatim referenciraš plugin iz app.json:

{
  "expo": {
    "plugins": ["expo-settings"]
  }
}

Pogledaj službeni tutorial za config plugin i native modul za kompletan primjer koji dodaje app extension i modificira Podfile. Za advanced modove (dodavanje entitlementsa, background modova, custom URL scheme handling), @expo/config-plugins paket ima helper funkcije za sve česte slučajeve.

Testiranje modula i publish na npm

Ako si generirao standalone modul (bez --local zastavice), CLI ti je već napravio example/ Expo app unutar modula. To je tvoja razvojna aplikacija, pa je pokreni s npm run open:ios ili npm run open:android iz root direktorija modula. Metro će watchati Swift i Kotlin fileove i rebuildati aplikaciju automatski kad ih promijeniš.

Za unit testove native koda koristi Xcode XCTest targete i Android JUnit testove. Expo Modules API ne ograničava standardne native test alate. Za integration testove koji verificiraju JS ↔ native boundary preferiram Maestro flow-ove; više detalja o E2E setupu je u Expo Module API referenci.

Publish je standardni npm workflow: npm version patch, pa npm publish. Prije publisha provjeri da expo-module.config.json ima točan platforms array i da package.json uključuje ios/, android/ i src/ direktorije u files polju. Bez toga korisnici tvog paketa neće dobiti native izvorni kod nakon npm install.

Česta pitanja

Radi li Expo Modules API u bare React Native projektu bez Expo SDK-a?

Da, ali moraš instalirati expo paket i pokrenuti npx install-expo-modules koji doda potrebnu integraciju u iOS i Android projekte. Nakon toga, bare RN projekt može koristiti bilo koji Expo module kao regularni npm paket.

Mogu li koristiti C++ kod u Expo Modules API-ju?

Ne direktno kao u Turbo Modules, jer je Expo Modules API namijenjen Swiftu i Kotlinu. Ako trebaš C++, možeš ga uključiti kao dio native izvornog koda i pozvati ga iz Swifta preko bridging headera ili iz Kotlina preko JNI. Za punopravni JSI ↔ C++ pipeline Turbo Modules su primjereniji.

Trebam li migraciju modula za React Native 0.76+ i bridgeless mode?

Ne trebaš. Moduli napisani po Expo Modules API-ju automatski rade i u bridgeless modeu i u legacy bridge modeu. Framework interno drži oba adaptera. Ovo je jedan od glavnih razloga zašto ga preporučam preko ručnog pisanja Turbo Modula za većinu use-caseova.

Kako debugirati Swift i Kotlin kod unutar Expo modula?

Za iOS otvori ios direktorij s xed ios i debugiraj kroz Xcode s breakpointima pod Pods → Development Pods → <ModuleName>. Za Android otvori android direktorij u Android Studiju kao Gradle projekt i attachaj debugger na Metro proces. Log izjave (Swift print, Kotlin Log.d) pojavljuju se u Metro terminalu.

Koja je razlika između local i inline modula?

Local moduli žive u modules/ direktoriju s vlastitim package.json-om i expo-module.config.json-om, i pogodni su za srednje složene module koji imaju više fileova. Inline moduli su Swift/Kotlin fileovi direktno pored app/ koda, bez scaffoldinga, i pogodni su za mali, jednokratni native poziv koji ne opravdava zaseban paket.

Jake Morrison
O Autoru Jake Morrison

React Native lead engineer who's shipped six apps and learned six different lessons. Bullish on the New Architecture.