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:
| Component | Purpose |
|---|---|
| <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
| Aspect | React Native | Native (Swift / Kotlin) |
|---|---|---|
| Codebase | One shared codebase | Separate iOS and Android code |
| Language | JavaScript / TypeScript | Swift / Kotlin |
| Iteration speed | Fast — hot reload, OTA updates | Slower — full recompiles |
| Raw performance | Near-native for most apps | Best possible |
| Newest OS features | May lag until a module ships | Available day one |
| Best for | Most product apps, small teams | Games, 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
- 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. - Add a
(tabs)group with Home and Profile tabs, each with an icon and a teal active tint. - Create a dynamic route
posts/[id].tsxand navigate to it from a FlatList of posts, reading the id withuseLocalSearchParams. - Fetch a list from a public API with TanStack Query, render it in a memoized FlatList, and add pull-to-refresh and infinite scroll.
- Persist a "dark mode" toggle with AsyncStorage so the setting survives an app restart.
- Request camera permission with
useCameraPermissions, show a live camera preview, and build a production binary witheas build.