Start with one product contract, then choose the smallest platform boundary that expresses it. Use universal React Native or Expo UI components for shared behavior, platform files when the implementation truly differs, and custom native modules only when the platform API is otherwise unavailable.
Keep the Public Component Contract Shared
Both platform files export the same props. Callers never branch on Platform.OS.
// components/PlatformSettings/types.ts
export type PlatformSettingsProps = {
notificationsEnabled: boolean;
onNotificationsChange(value: boolean): void;
onOpenAccount(): void;
};
// components/PlatformSettings/PlatformSettings.ios.tsx
import type { PlatformSettingsProps } from "./types";
import { Host, Button, Column, Switch } from "@expo/ui";
export function PlatformSettings(props: PlatformSettingsProps) {
return (
<Host style={{ flex: 1 }}>
<Column spacing={12}>
<Switch
label="Notifications"
value={props.notificationsEnabled}
onValueChange={props.onNotificationsChange}
/>
<Button label="Account" onPress={props.onOpenAccount} />
</Column>
</Host>
);
}
// components/PlatformSettings/PlatformSettings.android.tsx
import type { PlatformSettingsProps } from "./types";
import { Pressable, Switch, Text, View } from "react-native";
export function PlatformSettings(props: PlatformSettingsProps) {
return (
<View style={{ gap: 12 }}>
<Text>Notifications</Text>
<Switch
accessibilityLabel="Notifications"
value={props.notificationsEnabled}
onValueChange={props.onNotificationsChange}
/>
<Pressable
accessibilityRole="button"
onPress={props.onOpenAccount}
style={{ minHeight: 44, justifyContent: "center" }}
>
<Text>Account</Text>
</Pressable>
</View>
);
}
Resolve the component through an index.ts import and let Metro select the platform extension. Do not let platform-specific domain logic drift into these renderers.
Use Universal Expo UI for Native Semantics
@expo/ui universal components render through SwiftUI on iOS, Jetpack Compose on Android, and a web fallback. Wrap a universal subtree in Host.
import { Button, Column, Host, Text } from "@expo/ui";
export function EmptyDownloads({ onBrowse }: { onBrowse(): void }) {
return (
<Host style={{ flex: 1 }}>
<Column spacing={12} alignment="center">
<Text>No downloads yet</Text>
<Button label="Browse episodes" onPress={onBrowse} />
</Column>
</Host>
);
}
Prefer native controls when they already solve focus, semantics, menus, toggles, and platform behavior. Confirm current support on every target because Expo UI APIs evolve with the installed SDK.
Use Native State for High-Frequency Native UI
useNativeState can update a SwiftUI control without a React render for each character or gesture frame. Use get and set for React Compiler compatibility.
import { Host, TextField, useNativeState } from "@expo/ui/swift-ui";
import { accessibilityLabel } from "@expo/ui/swift-ui/modifiers";
export function UppercaseCodeField() {
const code = useNativeState("");
return (
<Host matchContents>
<TextField
text={code}
placeholder="Invite code"
modifiers={[accessibilityLabel("Invite code")]}
onTextChange={(next) => {
"worklet";
const normalized = next.replace(/[^a-z0-9]/gi, "").slice(0, 8).toUpperCase();
if (normalized !== next) code.set(normalized);
}}
/>
</Host>
);
}
Use ordinary React state for product state that drives several components, persistence, or networking. Native observable state is for a native subtree with a clear owner.
Isolate Native Capabilities Behind an Adapter
import { Platform } from "react-native";
export interface SharePreviewCapability {
isAvailable(): Promise<boolean>;
present(fileUri: string): Promise<void>;
}
export function createSharePreviewCapability(): SharePreviewCapability {
const createCapability = Platform.select({
ios: createIOSSharePreview,
android: createAndroidSharePreview,
default: createUnsupportedSharePreview,
}) ?? createUnsupportedSharePreview;
return createCapability();
}
This is a better use of Platform.select than scattering render branches through a screen. The unsupported adapter should return a useful fallback, not crash at import time.
Decide When to Build a Native Module
Create a native module or view when all are true:
- The required OS API is missing from React Native and Expo modules.
- A JavaScript implementation cannot meet correctness or performance needs.
- The feature has a stable typed boundary and an explicit fallback.
- The team can test and maintain iOS and Android lifecycle behavior.
Keep events coarse. Native code should emit “download completed” or measured samples at a documented rate, not flood JavaScript with every internal callback.
Native UI Review Checks
- The same task has the same label and outcome on both platforms.
- Native control state is accessible and not communicated by color alone.
- Safe areas, keyboard behavior, text scaling, RTL, and dark mode are tested.
- Platform files share one prop contract and one domain model.
- Every native subscription, listener, or shared object has an owner and cleanup.
Sources
Behavior is checked against Expo UI’s universal Host and SwiftUI useNativeState documentation. The platform boundary and capability adapters are independently written.