Skip to content

React Native Component Architecture

A React Native guide for coding agents. Also covers react native architecture, universal components, native component boundary, platform files.

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:

LaneReach for it whenAccept the tradeoff
Shared React Native viewsBrand expression and exact layout matterYou own cross-platform polish
Universal @expo/ui componentsNative controls and one shared tree matterStyling freedom is intentionally smaller
Platform-specific Expo UIA native control or behavior is not exposed universallyThe 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.OS conditionals 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.tsx and .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 Host around 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-ui or @expo/ui/jetpack-compose only 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:

  • Platform only in *.ios.tsx, *.android.tsx, or platform utility modules
  • SwiftUI and Compose entry points only in platform leaf directories
  • direct @expo/ui imports only inside src/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 Host boundaries 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.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-react-native-guide({ topic: "react-native-component-architecture" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems