When You Need Native Code
How to decide between an existing library, a config plugin, and writing your own Swift or Kotlin module.
npx expo install <library> # step one, before any native codeNative code is Swift, Kotlin, Objective-C or Java compiled into your app and called from JavaScript. React Native needs it for anything the JavaScript engine cannot reach on its own: the camera sensor, the keychain, the vibration motor, a platform widget.
Most apps never write any. The question is answered in three steps, in this order.
The three steps
- An existing library. The Expo SDK and the wider ecosystem already cover camera, notifications, location, secure storage, files, haptics and much more. Install it and move on.
- A config plugin. Sometimes the native code exists but the native project needs a setting: a usage description in
Info.plist, an entry inAndroidManifest.xml. That is a configuration change, not a module. - Your own module. Only when no library exposes the API and no setting will do: a vendor SDK shipped as a framework, a platform feature nobody has wrapped yet, or a tight loop that is too slow in JavaScript.
What trips people up
Reaching for step three first. It is by far the most expensive option: two languages, two build systems, and a maintenance cost on every React Native upgrade. Search reactnative.directory and the Expo SDK before you open Xcode.
The other trap is Expo Go. It contains a fixed set of native modules chosen in advance. The moment your app depends on native code that Expo Go did not compile, you need a development build instead — Expo Go cannot load it at runtime.
Example
// App.tsx
// Both capabilities here are native, and neither needed a line of Swift or
// Kotlin from us: expo-haptics and expo-device already own that code.
import { useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import * as Haptics from "expo-haptics";
import * as Device from "expo-device";
export default function App() {
const [taps, setTaps] = useState(0);
async function onPress() {
// Drives the vibration motor through the library's native module.
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
setTaps((n) => n + 1);
}
return (
<View style={styles.container}>
{/* modelName is read from native code; it can be null on some devices. */}
<Text style={styles.model}>{Device.modelName ?? "unknown device"}</Text>
<Pressable onPress={onPress} style={styles.button}>
<Text style={styles.label}>Tap</Text>
</Pressable>
<Text>{taps} taps</Text>
</View>
);
}
const styles = StyleSheet.create({
// flexDirection defaults to column here, unlike CSS flexbox, so these
// three children stack vertically without being told to.
container: { flex: 1, alignItems: "center", justifyContent: "center", gap: 16 },
model: { fontSize: 18, fontWeight: "600" },
button: { backgroundColor: "#1f6feb", paddingHorizontal: 24, paddingVertical: 12, borderRadius: 8 },
label: { color: "#ffffff", fontSize: 16 },
});When to use it
- Decide whether a request for "a widget on the home screen" needs native work or an existing package.
- Explain to a team why adding one library has just ended their use of Expo Go.
- Audit an inherited app for native modules that could be replaced by maintained libraries.
More examples
A config plugin instead of a module
The camera roll needs a usage string in Info.plist or iOS kills the app on first access. The native code already exists inside expo-image-picker; only the project setting is missing. Its config plugin writes that setting during prebuild. Reach for this whenever a library's README says "add this key to Info.plist" — it is step two of the ladder, not step three.
{
"expo": {
"name": "Receipts",
"slug": "receipts",
"plugins": [
[
"expo-image-picker",
{
"photosPermission": "Receipts needs your library to attach a photo to an expense."
}
]
]
}
}Deciding per platform
Some features only exist on one platform, so "do we need native code" has a different answer per platform. A small table keeps that decision in one file instead of scattered Platform.OS checks. Useful when planning a release: it tells you which platform's native work you can defer.
// lib/capabilities.ts
import { Platform } from "react-native";
type Capability = "homeWidget" | "liveActivity" | "quickSettingsTile";
// Features with no cross-platform library wrapper as of Expo SDK 57.
const OWNED_BY: Record<Capability, "ios" | "android"> = {
homeWidget: "ios",
liveActivity: "ios",
quickSettingsTile: "android",
};
export function needsOwnNativeModule(cap: Capability): boolean {
return OWNED_BY[cap] === Platform.OS;
}
console.log(needsOwnNativeModule("quickSettingsTile"));
// Output on Android: true
// Output on iOS: falseReplacing a module with plain JavaScript
Performance is the weakest reason to write native code. Hermes is the default engine, and string or array work at app scale is rarely the bottleneck. Measure first: if the slow part is layout or image decoding, a native module will not help. Delete a module whenever JavaScript can do the job, because JavaScript ships over the air and Swift does not.
// lib/slugify.ts
// An early draft of this app called a Kotlin helper for text normalisation.
// Hermes does it fast enough, and this version needs no rebuild to change.
export function slugify(input: string): string {
return input
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "") // strip combining accents
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
console.log(slugify("Café Münster!"));
// Output: cafe-munster
Discussion