React Native gives a product several UI lanes: shared React Native views, universal native components, and platform-specific SwiftUI or Jetpack Compose views. Maintainability comes from making that boundary explicit. A component should belong to one lane, while screens should remain unaware of the platform details below them.
Choose One UI Lane per Component
Pick the lane from the product requirement, not from a preference for one library:
| Lane | Reach for it when | Accept the tradeoff |
|---|---|---|
| Shared React Native views | Brand expression and exact layout matter | You own cross-platform polish |
Universal @expo/ui components | Native controls and one shared tree matter | Styling freedom is intentionally smaller |
| Platform-specific Expo UI | A native control or behavior is not exposed universally | The rendered view is implemented twice |
Do not blend the three models throughout one screen. If almost every line needs a platform modifier or conditional, move that implementation into a platform leaf or choose a different lane.
Keep Screens as Composition
Use three layers:
Screen feature composition and data dependencies
Primitive reusable layout, styling, accessibility, and theme defaults
Leaf platform-specific view, modifier, or native integration
A screen should read like a table of contents for the feature. It can know that a settings section contains notification and appearance rows. It should not know the Android corner treatment or which SwiftUI modifier produces an iOS effect.
Warning signs in a screen file include:
Platform.OSconditionals mixed into the main tree- repeated row padding, icon slots, radii, or separators
- direct imports from both SwiftUI and Jetpack Compose entry points
- hardcoded platform colors
- duplicated business logic in
.ios.tsxand.android.tsx
Push those details down until the screen expresses intent again.
Wrap Moving or Constrained APIs
Importing an external component API in every feature couples the whole product to its prop names and defaults. Put a thin app-owned layer in front of @expo/ui:
// src/ui/Text.tsx
import { Text as ExpoText, type TextProps } from "@expo/ui";
import { useTheme } from "@/theme";
type AppTextProps = TextProps & {
variant?: "title" | "body" | "caption";
};
export function Text({
variant = "body",
textStyle,
...props
}: AppTextProps) {
const theme = useTheme();
return (
<ExpoText
textStyle={{ ...theme.text[variant], ...textStyle }}
{...props}
/>
);
}
Features import from @/ui, not directly from the external package. The wrapper is useful only when it adds a stable product decision: semantic variants, theme defaults, accessibility behavior, or an intentional narrower API. A re-export with no policy adds indirection without protection.
Put Repeated Native Details in Primitives
The moment a platform-sensitive row is copied, promote it to a primitive. The primitive owns details that should not drift: spacing, icon placement, press behavior, section position, separators, and platform modifiers.
type SectionPosition = "first" | "middle" | "last" | "only";
type SettingsRowProps = {
label: string;
position?: SectionPosition;
onPress?: () => void;
trailing?: React.ReactNode;
};
export function SettingsRow({
label,
position = "only",
onPress,
trailing,
}: SettingsRowProps) {
return (
<Row
onPress={onPress}
modifiers={sectionShape(position)}
style={styles.row}
>
<Text>{label}</Text>
<Spacer flexible />
{trailing}
</Row>
);
}
The screen supplies content and actions. The primitive supplies the native contract.
Render Twice, Keep One Controller
When iOS and Android genuinely need separate views, split the files but not the feature logic:
PromptBar.ios.tsx
PromptBar.android.tsx
usePromptController.ts
The controller owns state, validation, network work, and derived values. Each platform file renders the controller through its own native components. If several consumers need the same controller instance, provide it through context above both leaves.
This prevents a behavior fix from landing on one platform while the other keeps stale logic.
Treat the Native Host as a Boundary
Universal Expo UI components require a Host. Treat it as an architectural boundary, not a wrapper to scatter around individual controls.
- Prefer one
Hostaround a coherent native subtree. - Keep rapid input state on the native side when the API supports it, so typing does not rerender an unrelated React tree.
- Use the universal package root when one component tree should serve both platforms.
- Drop to
@expo/ui/swift-uior@expo/ui/jetpack-composeonly behind a platform leaf when universal components cannot express the requirement.
Enforce the Boundary
Architecture that relies only on memory erodes. Add import restrictions so platform APIs appear only in approved locations:
Platformonly in*.ios.tsx,*.android.tsx, or platform utility modules- SwiftUI and Compose entry points only in platform leaf directories
- direct
@expo/uiimports only insidesrc/ui
This turns an abstraction leak into an immediate lint failure instead of a future refactor.
Checklist
- Every component has one clear UI lane
- Screen files compose features without platform conditionals
- Repeated native layout details live in primitives
- External native component APIs sit behind app-owned policy wrappers
- Platform files render shared controllers instead of duplicating logic
- Theme values come from semantic tokens, not screen-level literals
- Native
Hostboundaries are deliberate and coarse-grained - Import rules prevent platform details from leaking back into screens
- Universal components are replaced when the design is fighting their API
Sources
Check component and platform boundaries against React Native's platform-specific code guidance and the current Expo UI universal component documentation.