React Native Land_

// construindo apps mobile melhores

# Código nativo com Expo Modules

2026-11-12#lungo

Às vezes o JavaScript não basta: você precisa de uma API que só a plataforma tem. A Expo Modules API permite escrever esse código em Swift e Kotlin com o mesmo formato dos dois lados, e chamá-lo do TypeScript como qualquer outro módulo.

Vamos construir um pequeno. Ele lê o nível da bateria e avisa o app quando ele muda.

## Crie o módulo

Num app Expo, um módulo local fica em modules/ e é ligado no próximo build nativo:

npx create-expo-module@latest --local

Dê a ele o nome battery. Reduzido ao que importa aqui, ele tem esta forma:

modules/battery/
  expo-module.config.json
  index.ts
  src/BatteryModule.ts
  ios/BatteryModule.swift
  android/src/main/java/expo/modules/battery/BatteryModule.kt

O arquivo de configuração diz ao autolinking qual classe carregar em cada plataforma:

expo-module.config.jsonjson
{
  "platforms": ["apple", "android"],
  "apple": { "modules": ["BatteryModule"] },
  "android": { "modules": ["expo.modules.battery.BatteryModule"] }
}

## Swift

Um módulo é uma classe com uma definition(). Name é o nome que o JavaScript pede, AsyncFunction devolve uma promise e Events declara o que o módulo pode enviar:

ios/BatteryModule.swiftswift
import ExpoModulesCore
import UIKit

public class BatteryModule: Module {
  private var observer: NSObjectProtocol?

  public func definition() -> ModuleDefinition {
    Name("Battery")

    Events("onLevelChange")

    AsyncFunction("getLevelAsync") { () -> Float in
      UIDevice.current.isBatteryMonitoringEnabled = true
      return UIDevice.current.batteryLevel
    }.runOnQueue(.main)

    OnStartObserving {
      UIDevice.current.isBatteryMonitoringEnabled = true
      self.observer = NotificationCenter.default.addObserver(
        forName: UIDevice.batteryLevelDidChangeNotification,
        object: nil,
        queue: .main
      ) { [weak self] _ in
        self?.sendEvent("onLevelChange", ["level": UIDevice.current.batteryLevel])
      }
    }

    OnStopObserving {
      if let observer = self.observer {
        NotificationCenter.default.removeObserver(observer)
      }
      self.observer = nil
    }
  }
}

UIDevice pertence à thread principal, então runOnQueue(.main) executa a função nela. No simulador o nível é -1: ele não tem bateria.

## Kotlin

A mesma definição, palavra por palavra, no Android. Não há um callback de nível de bateria para observar, então o módulo escuta o broadcast ACTION_BATTERY_CHANGED do sistema:

android/.../BatteryModule.ktkotlin
package expo.modules.battery

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

class BatteryModule : Module() {
  private val context: Context
    get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()

  private val receiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
      sendEvent("onLevelChange", mapOf("level" to levelOf(intent)))
    }
  }

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

    Events("onLevelChange")

    AsyncFunction("getLevelAsync") {
      val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
      intent?.let { levelOf(it) } ?: -1f
    }

    OnStartObserving {
      context.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
    }

    OnStopObserving {
      context.unregisterReceiver(receiver)
    }
  }

  private fun levelOf(intent: Intent): Float {
    val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
    val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
    return if (level < 0 || scale <= 0) -1f else level / scale.toFloat()
  }
}

OnStartObserving e OnStopObserving rodam quando o JavaScript adiciona o primeiro listener e remove o último, então nada fica escutando enquanto ninguém tem interesse.

## TypeScript

Uma só declaração tipa as duas plataformas:

src/BatteryModule.tsts
import { NativeModule, requireNativeModule } from 'expo';

type BatteryEvents = {
  onLevelChange(event: { level: number }): void;
};

declare class BatteryModule extends NativeModule<BatteryEvents> {
  getLevelAsync(): Promise<number>;
}

export default requireNativeModule<BatteryModule>('Battery');

O index.ts do módulo a reexporta, e um hook esconde a inscrição:

useBatteryLevel.tsts
import { useEffect, useState } from 'react';
import Battery from './modules/battery';

export function useBatteryLevel(): number | null {
  const [level, setLevel] = useState<number | null>(null);

  useEffect(() => {
    let active = true;
    Battery.getLevelAsync().then((value) => {
      if (active) setLevel(value);
    });
    const subscription = Battery.addListener('onLevelChange', (event) => setLevel(event.level));
    return () => {
      active = false;
      subscription.remove();
    };
  }, []);

  return level;
}
BatteryBadge.tsxtsx
import { Text } from 'react-native';
import { useBatteryLevel } from './useBatteryLevel';

export function BatteryBadge() {
  const level = useBatteryLevel();
  if (level === null || level < 0) return <Text>Bateria: desconhecida</Text>;
  return <Text>Bateria: {Math.round(level * 100)}%</Text>;
}

## Rode

O Expo Go só tem o código nativo com que é distribuído, então um módulo seu precisa de um development build:

npx expo run:ios
npx expo run:android

Pronto: uma definição em duas linguagens e um módulo tipado no JavaScript. A documentação da Expo Modules API cobre views, records e config plugins.