contentintech
Learn/mobile dev/React Native
Intermediate~20 min read

React Native

Build native iOS and Android apps from one React codebase with Expo, file-based Expo Router navigation, EAS builds, core components, StyleSheet and Flexbox, hooks, data fetching, native device APIs, and list performance.

ExpoComponentsNavigationHooks

What is React Native?

React Native lets you build genuinely native mobile apps for iOS and Android using React. Instead of rendering to a DOM like React on the web, your components map to real native views — a <View> becomes a UIView on iOS and an android.view.View on Android. You write JavaScript or TypeScript once and ship two native apps.

Since the New Architecture (Fabric renderer + JSI + TurboModules) became the default, communication between JS and native happens synchronously without the old bridge, so gestures, animations, and lists feel far smoother than early React Native.

Expo — the default way to build

In 2026, Expo is the recommended toolchain for almost every new project. It bundles a managed workflow, a huge library of native modules (camera, location, notifications), Expo Router for navigation, and EAS (Expo Application Services) for cloud builds and over-the-air updates. You rarely need to open Xcode or Android Studio for day-to-day work.

Expo vs bare workflow

The old "eject" concept is gone. Expo now uses Continuous Native Generation (CNG): your android/ and ios/ folders are regenerated from config via npx expo prebuild. You can still add fully custom native code when you need it.

Setup

Create a new project with the TypeScript + Expo Router template, then start the dev server:

# Scaffold a new app (Expo Router, TypeScript)
npx create-expo-app@latest my-app
cd my-app

# Start the dev server
npx expo start

# Press i (iOS simulator), a (Android emulator),
# or scan the QR code with the Expo Go app on a real device

The generated project uses the app/ directory for file-based routing. Configuration lives in app.json (or app.config.ts for dynamic config).

Core Components

React Native ships a set of primitive components that replace HTML elements. There is no <div> or <p> — you compose UI from these:

ComponentPurpose
<View>Layout container — like a div, uses Flexbox
<Text>All text must live inside a Text element
<ScrollView>Scrollable container — renders all children at once
<FlatList>Virtualized list for large or dynamic data
<Image>Local or remote images (use expo-image for caching)
<Pressable>Modern touch handler — replaces TouchableOpacity
<TextInput>Editable text field
import { View, Text, Pressable, StyleSheet } from "react-native";

export default function Welcome() {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Hello, React Native</Text>
      <Pressable
        style={styles.button}
        onPress={() => console.log("pressed")}
      >
        <Text style={styles.buttonText}>Tap me</Text>
      </Pressable>
    </View>
  );
}

ScrollView vs FlatList

Use ScrollView only for small, known amounts of content (a settings screen). For any list backed by an array — feeds, search results — use FlatList, which only renders items on screen.

Styling — StyleSheet & Flexbox

There is no CSS. You style with JavaScript objects, typically created through StyleSheet.create. Property names are camelCased (backgroundColor) and numbers are density-independent pixels.

const styles = StyleSheet.create({
  container: {
    flex: 1,               // fill available space
    justifyContent: "center",  // main axis (vertical by default)
    alignItems: "center",      // cross axis (horizontal)
    padding: 16,
    gap: 12,               // spacing between children
  },
  title: { fontSize: 24, fontWeight: "600", color: "#0f172a" },
  button: { backgroundColor: "#14b8a6", paddingHorizontal: 20, paddingVertical: 10, borderRadius: 8 },
  buttonText: { color: "#fff", fontWeight: "500" },
});

The key difference from web: every View is a Flexbox container by default, and the default direction is column (top to bottom), not row. Set flexDirection: "row" for horizontal layouts. Many teams also use NativeWind to write Tailwind classes in RN.

Props, State & Hooks

Components are just React functions, so the same hooks you know from the web apply. useState holds local state, useEffect runs side effects, and useCallback memoizes callbacks so children do not re-render needlessly.

import { useState, useEffect, useCallback } from "react";
import { View, Text, Pressable } from "react-native";

function Counter({ step = 1 }: { step?: number }) {
  const [count, setCount] = useState<number>(0);

  // Runs when count changes
  useEffect(() => {
    console.log("count is now", count);
  }, [count]);

  // Stable reference — safe to pass to memoized children
  const increment = useCallback(() => {
    setCount((c) => c + step);
  }, [step]);

  return (
    <View>
      <Text>Count: {count}</Text>
      <Pressable onPress={increment}>
        <Text>Add {step}</Text>
      </Pressable>
    </View>
  );
}

Always use the functional updater form (setCount(c => c + 1)) when the next value depends on the previous one — it avoids stale-closure bugs.

Navigation with Expo Router

Expo Router brings file-based routing to native. Every file in the app/ directory becomes a route, mirroring how Next.js works on the web.

app/
  _layout.tsx        # root layout (Stack navigator)
  index.tsx          # route: /
  about.tsx          # route: /about
  (tabs)/            # a group — parentheses don't add to the URL
    _layout.tsx      # Tabs navigator
    home.tsx         # route: /home
    profile.tsx      # route: /profile
  posts/
    [id].tsx         # dynamic route: /posts/42

A layout file declares the navigator. Here is a root stack:

// app/_layout.tsx
import { Stack } from "expo-router";

export default function RootLayout() {
  return (
    <Stack screenOptions={{ headerTintColor: "#14b8a6" }}>
      <Stack.Screen name="index" options={{ title: "Home" }} />
      <Stack.Screen name="posts/[id]" options={{ title: "Post" }} />
    </Stack>
  );
}

Navigate with the <Link> component or the imperative router object, and read params with useLocalSearchParams:

import { Link, router, useLocalSearchParams } from "expo-router";

// Declarative
<Link href="/posts/42">Open post 42</Link>

// Imperative
<Pressable onPress={() => router.push("/posts/42")}>...</Pressable>

// Inside app/posts/[id].tsx
const { id } = useLocalSearchParams<{ id: string }>();

Tabs

// app/(tabs)/_layout.tsx
import { Tabs } from "expo-router";
import { Ionicons } from "@expo/vector-icons";

export default function TabsLayout() {
  return (
    <Tabs screenOptions={{ tabBarActiveTintColor: "#14b8a6" }}>
      <Tabs.Screen name="home" options={{
        title: "Home",
        tabBarIcon: ({ color }) => <Ionicons name="home" color={color} size={22} />,
      }} />
      <Tabs.Screen name="profile" options={{ title: "Profile" }} />
    </Tabs>
  );
}

Expo Router is built on top of React Navigation, so its Stack and Tabs map directly to createNativeStackNavigator and createBottomTabNavigator. You can still use React Navigation directly if you prefer component-based routing, but file-based routing is now the default.

Data Fetching

Networking uses the standard fetch API. For anything beyond a single request, most teams reach for TanStack Query to handle caching, retries, and loading states.

import { useQuery } from "@tanstack/react-query";
import { ActivityIndicator, Text } from "react-native";

function usePosts() {
  return useQuery({
    queryKey: ["posts"],
    queryFn: async () => {
      const res = await fetch("https://api.example.com/posts");
      if (!res.ok) throw new Error("Request failed");
      return res.json();
    },
  });
}

function Feed() {
  const { data, isLoading, error } = usePosts();
  if (isLoading) return <ActivityIndicator />;
  if (error) return <Text>Something went wrong</Text>;
  return <PostList posts={data} />;
}

Local Storage — AsyncStorage

For simple key-value persistence (auth tokens, user preferences), use @react-native-async-storage/async-storage. Everything is stored as strings, so serialize objects with JSON. For sensitive values like tokens, prefer expo-secure-store.

import AsyncStorage from "@react-native-async-storage/async-storage";

async function saveSettings(settings: object) {
  await AsyncStorage.setItem("settings", JSON.stringify(settings));
}

async function loadSettings() {
  const raw = await AsyncStorage.getItem("settings");
  return raw ? JSON.parse(raw) : null;
}

Native Device APIs

Expo provides typed modules for device features. They handle the platform permission prompts for you — just request access before using the API.

import * as Location from "expo-location";
import { CameraView, useCameraPermissions } from "expo-camera";

// Location
async function getPosition() {
  const { status } = await Location.requestForegroundPermissionsAsync();
  if (status !== "granted") return null;
  return Location.getCurrentPositionAsync({});
}

// Camera — permissions via hook
function Scanner() {
  const [permission, requestPermission] = useCameraPermissions();
  if (!permission?.granted) {
    return <Pressable onPress={requestPermission}><Text>Allow camera</Text></Pressable>;
  }
  return <CameraView style={{ flex: 1 }} />;
}

Permission strings

iOS requires human-readable usage descriptions. With Expo you set these in app.json under each module's config plugin (for example expo-location's locationWhenInUsePermission) instead of hand-editing Info.plist.

Performance

The most common performance problems in RN apps are long lists and unnecessary re-renders. A well-configured FlatList only mounts visible rows:

import { FlatList } from "react-native";
import { memo } from "react";

const Row = memo(function Row({ item }: { item: Post }) {
  return <Text>{item.title}</Text>;
});

<FlatList
  data={posts}
  keyExtractor={(item) => item.id}       // stable keys, never index
  renderItem={({ item }) => <Row item={item} />}
  initialNumToRender={10}
  windowSize={5}
  removeClippedSubviews
  onEndReached={loadMore}                 // infinite scroll
  onEndReachedThreshold={0.5}
/>

Key tactics: give every row a stable keyExtractor, wrap row components in React.memo, memoize the callbacks you pass down, and consider FlashList from Shopify for very large or heterogeneous lists. Move heavy animations onto the UI thread with React Native Reanimated.

React Native vs Native Development

AspectReact NativeNative (Swift / Kotlin)
CodebaseOne shared codebaseSeparate iOS and Android code
LanguageJavaScript / TypeScriptSwift / Kotlin
Iteration speedFast — hot reload, OTA updatesSlower — full recompiles
Raw performanceNear-native for most appsBest possible
Newest OS featuresMay lag until a module shipsAvailable day one
Best forMost product apps, small teamsGames, heavy graphics, deep OS integration

Shipping with EAS

EAS Build compiles your app in the cloud so you do not need local native toolchains, and EAS Update pushes JavaScript-only changes over the air without an app-store review.

# Build a production binary in the cloud
eas build --platform ios --profile production
eas build --platform android --profile production

# Submit to the App Store / Play Store
eas submit --platform ios

# Ship an over-the-air JS update
eas update --branch production --message "Fix checkout bug"

Practice Exercises

  1. Scaffold a new Expo Router app and build a home screen with a centered title and a Pressable that increments a counter stored in useState.
  2. Add a (tabs) group with Home and Profile tabs, each with an icon and a teal active tint.
  3. Create a dynamic route posts/[id].tsx and navigate to it from a FlatList of posts, reading the id with useLocalSearchParams.
  4. Fetch a list from a public API with TanStack Query, render it in a memoized FlatList, and add pull-to-refresh and infinite scroll.
  5. Persist a "dark mode" toggle with AsyncStorage so the setting survives an app restart.
  6. Request camera permission with useCameraPermissions, show a live camera preview, and build a production binary with eas build.

Section navigation