5 min read
Platform & Native
Handling iOS and Android differences without forking your codebase, and knowing when a native module is genuinely the answer.
Principles
Push platform differences to the edges
`Platform.OS` scattered through business logic makes every rule twice as hard to read and test. Isolate the difference in a component or a constant, and keep the branch out of the logic.
Prefer `Platform.select` over if-chains
It is exhaustive, it reads as a table of values rather than a branch, and it collapses to a constant. An `if (Platform.OS === 'ios')` chain invites a third case nobody handles.
Reach for a native module last
A native module doubles your build surface, blocks Expo Go, and needs someone who can debug Swift and Kotlin at 2am. Exhaust the JS ecosystem first — and when you do write one, own the maintenance honestly.
Safe areas are a layout concern, not a constant
Hardcoded status bar heights break on every new device notch, on Android gesture navigation, and in landscape. `useSafeAreaInsets` is the only thing that stays correct.
Test permission denial as a first-class path
Users deny camera and location permissions constantly, and on iOS the denial is permanent until they visit Settings. A flow that only works on grant is a flow that is broken for a real fraction of your users.
Patterns
What goes wrong, what to do instead, and why the difference matters.
Platform branching
Avoid
function getHeaderHeight() {
if (Platform.OS === "ios") {
return 88;
} else if (Platform.OS === "android") {
return 56;
}
return 64; // web? macOS? Nobody knows.
}A branch in logic, plus a fallback that documents nothing.
Prefer
const HEADER_HEIGHT = Platform.select({
ios: 88,
android: 56,
default: 64,
}) as number;
// Or push it into the file system when the whole
// implementation differs:
// Picker.ios.tsx
// Picker.android.tsx
// import { Picker } from "./Picker"; // resolved by extensionA value table rather than control flow. When implementations diverge entirely, platform file extensions keep both readable.
Safe area handling
Avoid
const STATUS_BAR = Platform.OS === "ios" ? 44 : 24;
<View style={{ paddingTop: STATUS_BAR }}>Wrong on every notched device, wrong in landscape, wrong with Android gesture navigation.
Prefer
const insets = useSafeAreaInsets();
<View style={{ paddingTop: insets.top }}>Correct on every device and orientation, including hardware that shipped after your app did.
Permissions as a real flow
Avoid
const granted = await requestCameraPermission();
if (granted) openCamera();
// Denied? Nothing happens. The button appears broken.Silent failure. The user taps again, gets nothing, and leaves a one-star review.
Prefer
const status = await requestCameraPermission();
if (status === "granted") return openCamera();
if (status === "blocked") {
// iOS will not re-prompt. Settings is the only path.
return promptOpenSettings();
}
return showRationale();Denial and permanent block are distinct states, and each has a real path forward.
Review checklist
What I look for when reviewing a pull request that touches this area.
- No `Platform.OS` inside domain logic.
- Divergent implementations use `.ios.tsx` / `.android.tsx` rather than branches.
- All insets come from `useSafeAreaInsets`.
- Every permission flow handles granted, denied, and blocked separately.
- Native modules are justified in writing, with a named maintainer.
- Tested on a notched iPhone and on Android gesture navigation.
Take the skill
Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.
.claude/skills/react-native-platform/SKILL.mdDrop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.
---
name: react-native-platform
description: >-
Handle iOS/Android platform differences, safe areas, permission flows, and
native module decisions in React Native. Use when writing
platform-specific code or integrating device capabilities.
argument-hint: "[capability]"
license: MIT
metadata:
author: John Felix Lim
version: "1.0.0"
---
# Platform & Native
Apply when code needs to differ between platforms or touch a device capability. Default to keeping the difference at the UI edge, and treat permission denial as a designed path rather than an error case.
## Rules
1. **Push platform differences to the edges** — `Platform.OS` scattered through business logic makes every rule twice as hard to read and test. Isolate the difference in a component or a constant, and keep the branch out of the logic.
2. **Prefer `Platform.select` over if-chains** — It is exhaustive, it reads as a table of values rather than a branch, and it collapses to a constant. An `if (Platform.OS === 'ios')` chain invites a third case nobody handles.
3. **Reach for a native module last** — A native module doubles your build surface, blocks Expo Go, and needs someone who can debug Swift and Kotlin at 2am. Exhaust the JS ecosystem first — and when you do write one, own the maintenance honestly.
4. **Safe areas are a layout concern, not a constant** — Hardcoded status bar heights break on every new device notch, on Android gesture navigation, and in landscape. `useSafeAreaInsets` is the only thing that stays correct.
5. **Test permission denial as a first-class path** — Users deny camera and location permissions constantly, and on iOS the denial is permanent until they visit Settings. A flow that only works on grant is a flow that is broken for a real fraction of your users.
## Patterns
### Platform branching
**Avoid** — A branch in logic, plus a fallback that documents nothing.
```ts
function getHeaderHeight() {
if (Platform.OS === "ios") {
return 88;
} else if (Platform.OS === "android") {
return 56;
}
return 64; // web? macOS? Nobody knows.
}
```
**Prefer** — A value table rather than control flow. When implementations diverge entirely, platform file extensions keep both readable.
```ts
const HEADER_HEIGHT = Platform.select({
ios: 88,
android: 56,
default: 64,
}) as number;
// Or push it into the file system when the whole
// implementation differs:
// Picker.ios.tsx
// Picker.android.tsx
// import { Picker } from "./Picker"; // resolved by extension
```
### Safe area handling
**Avoid** — Wrong on every notched device, wrong in landscape, wrong with Android gesture navigation.
```tsx
const STATUS_BAR = Platform.OS === "ios" ? 44 : 24;
<View style={{ paddingTop: STATUS_BAR }}>
```
**Prefer** — Correct on every device and orientation, including hardware that shipped after your app did.
```tsx
const insets = useSafeAreaInsets();
<View style={{ paddingTop: insets.top }}>
```
### Permissions as a real flow
**Avoid** — Silent failure. The user taps again, gets nothing, and leaves a one-star review.
```tsx
const granted = await requestCameraPermission();
if (granted) openCamera();
// Denied? Nothing happens. The button appears broken.
```
**Prefer** — Denial and permanent block are distinct states, and each has a real path forward.
```tsx
const status = await requestCameraPermission();
if (status === "granted") return openCamera();
if (status === "blocked") {
// iOS will not re-prompt. Settings is the only path.
return promptOpenSettings();
}
return showRationale();
```
## Review checklist
- [ ] No `Platform.OS` inside domain logic.
- [ ] Divergent implementations use `.ios.tsx` / `.android.tsx` rather than branches.
- [ ] All insets come from `useSafeAreaInsets`.
- [ ] Every permission flow handles granted, denied, and blocked separately.
- [ ] Native modules are justified in writing, with a named maintainer.
- [ ] Tested on a notched iPhone and on Android gesture navigation.
---
From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim