React Native lets you build real iOS and Android apps using JavaScript — no Swift, no Kotlin required. One codebase, two platforms, production-ready apps. This tutorial takes you from zero to a working app step by step.
What you'll learn
- Setting up a React Native development environment
- Core components and styling
- Navigation between screens
- State management with hooks and Zustand
- Fetching data from APIs
- Storing data locally
- Device features (camera, location, notifications)
- Publishing to the App Store and Play Store
Prerequisites
You need basic JavaScript knowledge (variables, functions, arrays, objects). React experience helps but isn't required — this tutorial covers what you need.
1. Environment setup
Option A — Expo (recommended for beginners)
Expo is the fastest way to start. No Xcode or Android Studio required.
# Install Expo CLI
npm install -g expo-cli
# Create a new project
npx create-expo-app MyFirstApp
cd MyFirstApp
# Start the development server
npx expo start
Scan the QR code with the Expo Go app on your phone. Your app runs instantly.
Option B — React Native CLI (full control)
For full native access, use the React Native CLI.
macOS (for iOS development):
# Install Xcode from the App Store, then:
xcode-select --install
# Install CocoaPods
sudo gem install cocoapods
# Install Android Studio (for Android)
# Set ANDROID_HOME in ~/.zshrc:
export ANDROID_HOME=$HOME/Library/Android/sdk
export PATH=$PATH:$ANDROID_HOME/emulator
export PATH=$PATH:$ANDROID_HOME/platform-tools
Windows (Android only):
# Install Android Studio
# Set ANDROID_HOME in System Environment Variables:
# ANDROID_HOME = C:\Users\<YourUser>\AppData\Local\Android\Sdk
Create and run the project:
npx react-native@latest init MyFirstApp
cd MyFirstApp
# iOS
npx react-native run-ios
# Android
npx react-native run-android
Expo vs React Native CLI
| Feature | Expo | React Native CLI |
|---|---|---|
| Setup time | 5 minutes | 1–2 hours |
| Native modules | Limited (but growing) | Full access |
| OTA updates | Yes (expo-updates) | Manual setup |
| Custom native code | Expo dev client | Yes |
| Build service | EAS Build | Self-managed |
| Best for | Beginners, MVPs | Full control, custom native |
2. Project structure
A new Expo project looks like this:
MyFirstApp/
├── app/ # Screens (Expo Router)
│ ├── (tabs)/
│ │ ├── index.tsx # Home tab
│ │ └── explore.tsx # Explore tab
│ └── _layout.tsx # Root layout
├── components/ # Reusable components
├── assets/ # Images, fonts
├── constants/ # Colors, config
└── package.json
Or for a basic Expo project (no router):
MyFirstApp/
├── App.tsx # Entry point
├── components/
├── screens/
├── assets/
└── package.json
3. Core components
React Native has its own components — not HTML elements.
| Web (HTML) | React Native | Purpose |
|---|---|---|
<div> |
<View> |
Container/layout |
<p>, <span> |
<Text> |
Display text |
<img> |
<Image> |
Display images |
<input> |
<TextInput> |
Text input |
<button> |
<TouchableOpacity> / <Pressable> |
Tappable element |
<ul>, <ol> |
<FlatList> |
Scrollable lists |
<div> (scrollable) |
<ScrollView> |
Scroll container |
Your first component
// App.tsx
import { View, Text, StyleSheet } from 'react-native';
export default function App() {
return (
<View style={styles.container}>
<Text style={styles.title}>Hello, React Native!</Text>
<Text style={styles.subtitle}>Welcome to mobile development</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5',
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#1a1a1a',
},
subtitle: {
fontSize: 16,
color: '#666',
marginTop: 8,
},
});
4. Styling in React Native
React Native uses JavaScript objects for styles — no CSS files. Properties use camelCase.
Flexbox layout
React Native uses Flexbox for layout (default direction is column, unlike web where it's row).
import { View, Text, StyleSheet } from 'react-native';
export default function Layout() {
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerText}>Header</Text>
</View>
<View style={styles.row}>
<View style={styles.box}>
<Text>Box 1</Text>
</View>
<View style={styles.box}>
<Text>Box 2</Text>
</View>
<View style={styles.box}>
<Text>Box 3</Text>
</View>
</View>
<View style={styles.footer}>
<Text>Footer</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1, // fill available space
padding: 16,
},
header: {
backgroundColor: '#6200ea',
padding: 20,
borderRadius: 8,
},
headerText: {
color: 'white',
fontSize: 20,
fontWeight: 'bold',
},
row: {
flexDirection: 'row', // horizontal layout
justifyContent: 'space-between', // space between items
marginTop: 16,
},
box: {
flex: 1, // equal width
backgroundColor: '#e8e8e8',
padding: 16,
margin: 4,
borderRadius: 8,
alignItems: 'center',
},
footer: {
marginTop: 'auto', // push to bottom
padding: 16,
alignItems: 'center',
},
});
Common style properties
const styles = StyleSheet.create({
box: {
// Size
width: 100,
height: 100,
// or: width: '50%'
// Spacing
margin: 8,
marginHorizontal: 16,
padding: 12,
paddingVertical: 8,
// Background and border
backgroundColor: '#fff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#ddd',
// Shadows (iOS)
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
// Elevation (Android shadow)
elevation: 3,
},
text: {
fontSize: 16,
fontWeight: '600', // '400' | '600' | '700' | 'bold'
color: '#1a1a1a',
textAlign: 'center',
letterSpacing: 0.5,
lineHeight: 24,
},
});
5. State and interactivity
useState hook
import { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<View style={styles.container}>
<Text style={styles.count}>{count}</Text>
<View style={styles.buttons}>
<TouchableOpacity
style={[styles.button, styles.decrement]}
onPress={() => setCount(c => c - 1)}
>
<Text style={styles.buttonText}>−</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.increment]}
onPress={() => setCount(c => c + 1)}
>
<Text style={styles.buttonText}>+</Text>
</TouchableOpacity>
</View>
<TouchableOpacity onPress={() => setCount(0)}>
<Text style={styles.reset}>Reset</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
count: { fontSize: 72, fontWeight: 'bold', marginBottom: 32 },
buttons: { flexDirection: 'row', gap: 16 },
button: {
width: 64, height: 64, borderRadius: 32,
alignItems: 'center', justifyContent: 'center',
},
decrement: { backgroundColor: '#ff5252' },
increment: { backgroundColor: '#00c853' },
buttonText: { fontSize: 32, color: 'white', fontWeight: 'bold' },
reset: { marginTop: 24, color: '#666', fontSize: 16 },
});
TextInput — handling user input
import { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
export default function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
function handleLogin() {
if (!email || !password) {
setError('Both fields are required.');
return;
}
setError('');
console.log('Logging in:', email);
}
return (
<View style={styles.container}>
<Text style={styles.title}>Login</Text>
{error ? <Text style={styles.error}>{error}</Text> : null}
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
/>
<TextInput
style={styles.input}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity style={styles.button} onPress={handleLogin}>
<Text style={styles.buttonText}>Login</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, justifyContent: 'center' },
title: { fontSize: 28, fontWeight: 'bold', marginBottom: 24 },
error: { color: 'red', marginBottom: 12 },
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 12,
marginBottom: 16,
fontSize: 16,
backgroundColor: 'white',
},
button: {
backgroundColor: '#6200ea',
padding: 16,
borderRadius: 8,
alignItems: 'center',
},
buttonText: { color: 'white', fontSize: 16, fontWeight: '600' },
});
6. Lists
FlatList — efficient scrollable lists
import { FlatList, View, Text, StyleSheet, TouchableOpacity } from 'react-native';
interface User {
id: string;
name: string;
email: string;
role: string;
}
const USERS: User[] = [
{ id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'Engineer' },
{ id: '2', name: 'Bob Smith', email: 'bob@example.com', role: 'Designer' },
{ id: '3', name: 'Carol Davis', email: 'carol@example.com', role: 'Manager' },
// ...more users
];
function UserCard({ user }: { user: User }) {
return (
<TouchableOpacity style={styles.card}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{user.name[0]}</Text>
</View>
<View style={styles.info}>
<Text style={styles.name}>{user.name}</Text>
<Text style={styles.email}>{user.email}</Text>
<Text style={styles.role}>{user.role}</Text>
</View>
</TouchableOpacity>
);
}
export default function UserList() {
return (
<FlatList
data={USERS}
keyExtractor={item => item.id}
renderItem={({ item }) => <UserCard user={item} />}
contentContainerStyle={styles.list}
ItemSeparatorComponent={() => <View style={styles.separator} />}
/>
);
}
const styles = StyleSheet.create({
list: { padding: 16 },
card: {
flexDirection: 'row',
backgroundColor: 'white',
borderRadius: 12,
padding: 16,
},
avatar: {
width: 48, height: 48, borderRadius: 24,
backgroundColor: '#6200ea',
alignItems: 'center', justifyContent: 'center',
marginRight: 12,
},
avatarText: { color: 'white', fontSize: 20, fontWeight: 'bold' },
info: { flex: 1 },
name: { fontSize: 16, fontWeight: '600', color: '#1a1a1a' },
email: { fontSize: 14, color: '#666', marginTop: 2 },
role: { fontSize: 12, color: '#6200ea', marginTop: 4 },
separator: { height: 8 },
});
FlatList props reference
| Prop | Purpose | Example |
|---|---|---|
data |
Array to render | data={users} |
keyExtractor |
Unique key per item | item => item.id |
renderItem |
Render each item | ({ item }) => <Card item={item} /> |
onEndReached |
Load more on scroll | () => fetchMore() |
onEndReachedThreshold |
When to trigger | 0.5 (50% from bottom) |
refreshing |
Pull-to-refresh state | {isRefreshing} |
onRefresh |
Pull-to-refresh handler | () => refetch() |
ListHeaderComponent |
Header element | <SearchBar /> |
ListEmptyComponent |
Empty state | <EmptyState /> |
numColumns |
Grid layout | 2 |
horizontal |
Horizontal scroll | true |
7. Navigation
The standard navigation library for React Native is React Navigation.
npm install @react-navigation/native @react-navigation/native-stack
npx expo install react-native-screens react-native-safe-area-context
Stack navigator
// App.tsx
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import HomeScreen from './screens/HomeScreen';
import DetailScreen from './screens/DetailScreen';
export type RootStackParamList = {
Home: undefined;
Detail: { id: string; title: string };
};
const Stack = createNativeStackNavigator<RootStackParamList>();
export default function App() {
return (
<NavigationContainer>
<Stack.Navigator
initialRouteName="Home"
screenOptions={{
headerStyle: { backgroundColor: '#6200ea' },
headerTintColor: 'white',
headerTitleStyle: { fontWeight: 'bold' },
}}
>
<Stack.Screen name="Home" component={HomeScreen} options={{ title: 'My App' }} />
<Stack.Screen
name="Detail"
component={DetailScreen}
options={({ route }) => ({ title: route.params.title })}
/>
</Stack.Navigator>
</NavigationContainer>
);
}
// screens/HomeScreen.tsx
import { View, Text, FlatList, TouchableOpacity, StyleSheet } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList } from '../App';
type Props = NativeStackScreenProps<RootStackParamList, 'Home'>;
const ITEMS = [
{ id: '1', title: 'Introduction to React Native' },
{ id: '2', title: 'Components and Styling' },
{ id: '3', title: 'Navigation Deep Dive' },
];
export default function HomeScreen({ navigation }: Props) {
return (
<FlatList
data={ITEMS}
keyExtractor={item => item.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.item}
onPress={() => navigation.navigate('Detail', { id: item.id, title: item.title })}
>
<Text style={styles.itemTitle}>{item.title}</Text>
<Text style={styles.arrow}>›</Text>
</TouchableOpacity>
)}
/>
);
}
const styles = StyleSheet.create({
list: { padding: 16 },
item: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: 'white',
padding: 16,
borderRadius: 8,
marginBottom: 8,
},
itemTitle: { fontSize: 16, color: '#1a1a1a' },
arrow: { fontSize: 22, color: '#6200ea' },
});
// screens/DetailScreen.tsx
import { View, Text, StyleSheet } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList } from '../App';
type Props = NativeStackScreenProps<RootStackParamList, 'Detail'>;
export default function DetailScreen({ route }: Props) {
const { id, title } = route.params;
return (
<View style={styles.container}>
<Text style={styles.id}>Article #{id}</Text>
<Text style={styles.title}>{title}</Text>
<Text style={styles.body}>
This is the detail screen for "{title}". In a real app, you'd fetch
content from an API using the id.
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24 },
id: { color: '#6200ea', fontSize: 14, fontWeight: '600', marginBottom: 8 },
title: { fontSize: 24, fontWeight: 'bold', color: '#1a1a1a', marginBottom: 16 },
body: { fontSize: 16, color: '#444', lineHeight: 24 },
});
Tab navigator
npm install @react-navigation/bottom-tabs
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Ionicons } from '@expo/vector-icons';
const Tab = createBottomTabNavigator();
function AppTabs() {
return (
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ color, size }) => {
const icons: Record<string, string> = {
Home: 'home',
Search: 'search',
Profile: 'person',
};
return <Ionicons name={icons[route.name] as any} size={size} color={color} />;
},
tabBarActiveTintColor: '#6200ea',
tabBarInactiveTintColor: '#999',
})}
>
<Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="Search" component={SearchScreen} />
<Tab.Screen name="Profile" component={ProfileScreen} />
</Tab.Navigator>
);
}
Navigation types overview
| Navigator | Use case | Package |
|---|---|---|
| Stack | Push/pop screens (drill-down) | @react-navigation/native-stack |
| Bottom tabs | Main sections of an app | @react-navigation/bottom-tabs |
| Drawer | Side menu navigation | @react-navigation/drawer |
| Top tabs | Swipeable tab sections | @react-navigation/material-top-tabs |
8. Fetching data from APIs
import { useEffect, useState } from 'react';
import {
View, Text, FlatList, ActivityIndicator,
TouchableOpacity, StyleSheet
} from 'react-native';
interface Post {
id: number;
title: string;
body: string;
}
export default function PostsScreen() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
async function fetchPosts() {
try {
setLoading(true);
setError(null);
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=20');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setPosts(data);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to fetch posts');
} finally {
setLoading(false);
}
}
useEffect(() => {
fetchPosts();
}, []);
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#6200ea" />
<Text style={styles.loadingText}>Loading posts...</Text>
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>Error: {error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={fetchPosts}>
<Text style={styles.retryText}>Retry</Text>
</TouchableOpacity>
</View>
);
}
return (
<FlatList
data={posts}
keyExtractor={item => item.id.toString()}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<View style={styles.card}>
<Text style={styles.cardTitle}>{item.title}</Text>
<Text style={styles.cardBody} numberOfLines={2}>
{item.body}
</Text>
</View>
)}
/>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
loadingText: { marginTop: 12, color: '#666' },
errorText: { color: '#c62828', fontSize: 16, textAlign: 'center', marginBottom: 16 },
retryButton: { backgroundColor: '#6200ea', padding: 12, borderRadius: 8 },
retryText: { color: 'white', fontWeight: '600' },
list: { padding: 16 },
card: { backgroundColor: 'white', borderRadius: 8, padding: 16, marginBottom: 8 },
cardTitle: { fontSize: 16, fontWeight: '600', color: '#1a1a1a', marginBottom: 8, textTransform: 'capitalize' },
cardBody: { fontSize: 14, color: '#666', lineHeight: 20 },
});
9. Local storage
AsyncStorage — simple key-value storage
npx expo install @react-native-async-storage/async-storage
import AsyncStorage from '@react-native-async-storage/async-storage';
// Store a value
await AsyncStorage.setItem('userId', '42');
await AsyncStorage.setItem('user', JSON.stringify({ id: 1, name: 'Alice' }));
// Read a value
const userId = await AsyncStorage.getItem('userId');
const rawUser = await AsyncStorage.getItem('user');
const user = rawUser ? JSON.parse(rawUser) : null;
// Remove
await AsyncStorage.removeItem('userId');
// Clear everything
await AsyncStorage.clear();
In a hook:
import { useEffect, useState } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
export function useTheme() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
AsyncStorage.getItem('theme').then(value => {
if (value === 'dark') setIsDark(true);
});
}, []);
async function toggleTheme() {
const next = !isDark;
setIsDark(next);
await AsyncStorage.setItem('theme', next ? 'dark' : 'light');
}
return { isDark, toggleTheme };
}
Storage options comparison
| Solution | Type | Use case |
|---|---|---|
| AsyncStorage | Key-value | Settings, tokens, small data |
| SQLite (expo-sqlite) | Relational DB | Structured local data |
| MMKV | Fast key-value | High-frequency reads/writes |
| Zustand + AsyncStorage | State + persistence | App-wide state |
| Secure Store (expo-secure-store) | Encrypted | Passwords, secrets, tokens |
10. State management
useState + useContext (small apps)
// contexts/AuthContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
interface AuthContextType {
user: { id: string; name: string } | null;
login: (name: string) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<{ id: string; name: string } | null>(null);
function login(name: string) {
setUser({ id: Date.now().toString(), name });
}
function logout() {
setUser(null);
}
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
Zustand (recommended for most apps)
npm install zustand
// stores/cartStore.ts
import { create } from 'zustand';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
total: () => number;
}
export const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) => set(state => {
const existing = state.items.find(i => i.id === item.id);
if (existing) {
return {
items: state.items.map(i =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) => set(state => ({
items: state.items.filter(i => i.id !== id),
})),
updateQuantity: (id, quantity) => set(state => ({
items: quantity <= 0
? state.items.filter(i => i.id !== id)
: state.items.map(i => i.id === id ? { ...i, quantity } : i),
})),
clearCart: () => set({ items: [] }),
total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}));
// Usage in a component
import { useCartStore } from '../stores/cartStore';
export default function CartScreen() {
const { items, removeItem, updateQuantity, total } = useCartStore();
return (
<View style={styles.container}>
<FlatList
data={items}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<View style={styles.item}>
<Text style={styles.name}>{item.name}</Text>
<Text style={styles.price}>${(item.price * item.quantity).toFixed(2)}</Text>
<TouchableOpacity onPress={() => removeItem(item.id)}>
<Text style={styles.remove}>Remove</Text>
</TouchableOpacity>
</View>
)}
/>
<Text style={styles.total}>Total: ${total().toFixed(2)}</Text>
</View>
);
}
State management comparison
| Solution | Complexity | Best for |
|---|---|---|
| useState | Minimal | Local component state |
| useContext + useState | Low | Auth, theme, small global state |
| Zustand | Low-medium | Most production apps |
| Redux Toolkit | Medium-high | Large teams, complex state |
| Jotai | Low | Atomic state, Next.js |
| TanStack Query | Medium | Server state, caching |
11. Device features
Camera (expo-camera)
npx expo install expo-camera
import { useState, useRef } from 'react';
import { View, TouchableOpacity, Text, StyleSheet } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
export default function CameraScreen() {
const [permission, requestPermission] = useCameraPermissions();
const cameraRef = useRef<CameraView>(null);
if (!permission) return <View />;
if (!permission.granted) {
return (
<View style={styles.container}>
<Text>Camera access is required.</Text>
<TouchableOpacity style={styles.button} onPress={requestPermission}>
<Text style={styles.buttonText}>Grant Permission</Text>
</TouchableOpacity>
</View>
);
}
async function takePhoto() {
const photo = await cameraRef.current?.takePictureAsync();
console.log('Photo URI:', photo?.uri);
}
return (
<View style={styles.container}>
<CameraView style={styles.camera} ref={cameraRef} facing="back">
<TouchableOpacity style={styles.captureButton} onPress={takePhoto} />
</CameraView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
camera: { flex: 1 },
captureButton: {
position: 'absolute', bottom: 40, alignSelf: 'center',
width: 72, height: 72, borderRadius: 36,
backgroundColor: 'white', borderWidth: 4, borderColor: '#ddd',
},
button: { backgroundColor: '#6200ea', padding: 12, borderRadius: 8 },
buttonText: { color: 'white', fontWeight: '600' },
});
Location (expo-location)
npx expo install expo-location
import { useState } from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import * as Location from 'expo-location';
export default function LocationScreen() {
const [location, setLocation] = useState<Location.LocationObject | null>(null);
const [error, setError] = useState<string | null>(null);
async function getLocation() {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
setError('Location permission denied.');
return;
}
const loc = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.High,
});
setLocation(loc);
}
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }}>
{location && (
<Text>
{location.coords.latitude.toFixed(6)}, {location.coords.longitude.toFixed(6)}
</Text>
)}
{error && <Text style={{ color: 'red' }}>{error}</Text>}
<TouchableOpacity
onPress={getLocation}
style={{ backgroundColor: '#6200ea', padding: 16, borderRadius: 8, marginTop: 16 }}
>
<Text style={{ color: 'white', fontWeight: '600' }}>Get My Location</Text>
</TouchableOpacity>
</View>
);
}
Push notifications (expo-notifications)
npx expo install expo-notifications
import { useEffect, useRef } from 'react';
import * as Notifications from 'expo-notifications';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
export async function registerForPushNotifications() {
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') return null;
const token = await Notifications.getExpoPushTokenAsync();
return token.data;
}
// Schedule a local notification
await Notifications.scheduleNotificationAsync({
content: {
title: 'Reminder',
body: 'Time to check the app!',
data: { screen: 'Home' },
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
seconds: 60,
},
});
12. Building a complete app — Todo list
Here's a complete mini-app combining everything you've learned:
// App.tsx — Complete Todo App
import { useState } from 'react';
import {
View, Text, TextInput, TouchableOpacity, FlatList,
StyleSheet, SafeAreaView, Keyboard
} from 'react-native';
interface Todo {
id: string;
text: string;
done: boolean;
}
export default function TodoApp() {
const [todos, setTodos] = useState<Todo[]>([]);
const [input, setInput] = useState('');
function addTodo() {
const text = input.trim();
if (!text) return;
setTodos(prev => [{ id: Date.now().toString(), text, done: false }, ...prev]);
setInput('');
Keyboard.dismiss();
}
function toggleTodo(id: string) {
setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
}
function deleteTodo(id: string) {
setTodos(prev => prev.filter(t => t.id !== id));
}
const remaining = todos.filter(t => !t.done).length;
return (
<SafeAreaView style={styles.safe}>
<View style={styles.container}>
<Text style={styles.title}>My Todos</Text>
<Text style={styles.subtitle}>{remaining} remaining</Text>
<View style={styles.inputRow}>
<TextInput
style={styles.input}
value={input}
onChangeText={setInput}
placeholder="Add a todo..."
onSubmitEditing={addTodo}
returnKeyType="done"
/>
<TouchableOpacity style={styles.addButton} onPress={addTodo}>
<Text style={styles.addButtonText}>+</Text>
</TouchableOpacity>
</View>
<FlatList
data={todos}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<View style={styles.todoItem}>
<TouchableOpacity
style={[styles.checkbox, item.done && styles.checkboxDone]}
onPress={() => toggleTodo(item.id)}
>
{item.done && <Text style={styles.checkmark}>✓</Text>}
</TouchableOpacity>
<Text style={[styles.todoText, item.done && styles.todoTextDone]}>
{item.text}
</Text>
<TouchableOpacity onPress={() => deleteTodo(item.id)}>
<Text style={styles.deleteButton}>✕</Text>
</TouchableOpacity>
</View>
)}
ListEmptyComponent={
<Text style={styles.empty}>No todos yet. Add one above!</Text>
}
/>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#f5f5f5' },
container: { flex: 1, padding: 24 },
title: { fontSize: 32, fontWeight: 'bold', color: '#1a1a1a' },
subtitle: { fontSize: 14, color: '#666', marginBottom: 24 },
inputRow: { flexDirection: 'row', marginBottom: 16, gap: 8 },
input: {
flex: 1,
backgroundColor: 'white',
borderRadius: 8,
padding: 12,
fontSize: 16,
borderWidth: 1,
borderColor: '#ddd',
},
addButton: {
backgroundColor: '#6200ea',
width: 48, height: 48,
borderRadius: 8,
alignItems: 'center', justifyContent: 'center',
},
addButtonText: { color: 'white', fontSize: 28, fontWeight: '300' },
todoItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'white',
borderRadius: 8,
padding: 12,
marginBottom: 8,
gap: 12,
},
checkbox: {
width: 24, height: 24, borderRadius: 12,
borderWidth: 2, borderColor: '#6200ea',
alignItems: 'center', justifyContent: 'center',
},
checkboxDone: { backgroundColor: '#6200ea', borderColor: '#6200ea' },
checkmark: { color: 'white', fontSize: 14, fontWeight: 'bold' },
todoText: { flex: 1, fontSize: 16, color: '#1a1a1a' },
todoTextDone: { textDecorationLine: 'line-through', color: '#999' },
deleteButton: { fontSize: 18, color: '#ccc', padding: 4 },
empty: { textAlign: 'center', color: '#999', marginTop: 48, fontSize: 16 },
});
13. Publishing your app
Using Expo Application Services (EAS)
# Install EAS CLI
npm install -g eas-cli
# Login to your Expo account
eas login
# Configure builds
eas build:configure
# Build for iOS (requires Apple Developer account - $99/year)
eas build --platform ios
# Build for Android
eas build --platform android
# Submit to stores
eas submit --platform ios
eas submit --platform android
eas.json configuration
{
"cli": {
"version": ">= 5.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}
Pre-launch checklist
| Item | iOS | Android |
|---|---|---|
| App icon (1024×1024 PNG) | Required | Required |
| Splash screen | Required | Required |
| Bundle ID / Package name | com.company.appname |
com.company.appname |
| Developer account | Apple ($99/year) | Google ($25 one-time) |
| Privacy policy URL | Required | Required |
| Screenshots | 3–10 per device size | 2–8 per screen size |
| App description | ≤4000 chars | ≤4000 chars |
| Content rating | Self-rated | IARC questionnaire |
14. Common patterns and best practices
Custom hook for API data
// hooks/useFetch.ts
import { useEffect, useState, useCallback } from 'react';
export function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetch_ = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setData(await res.json());
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error');
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => { fetch_(); }, [fetch_]);
return { data, loading, error, refetch: fetch_ };
}
// Usage
const { data: posts, loading, error, refetch } = useFetch<Post[]>(
'https://jsonplaceholder.typicode.com/posts?_limit=10'
);
Safe area handling
import { SafeAreaView } from 'react-native-safe-area-context';
// Always wrap your root screen with SafeAreaView
// to avoid content appearing behind the notch or home indicator
export default function Screen() {
return (
<SafeAreaView style={{ flex: 1 }} edges={['top', 'bottom']}>
{/* screen content */}
</SafeAreaView>
);
}
Platform-specific code
import { Platform, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
shadow: {
// iOS shadow
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
// Android shadow
elevation: 4,
},
text: {
fontFamily: Platform.select({
ios: 'SF Pro Display',
android: 'Roboto',
default: 'System',
}),
},
});
// Platform-specific files
// Button.ios.tsx → loaded on iOS
// Button.android.tsx → loaded on Android
// Button.tsx → fallback
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
Forgetting key in lists |
Performance warnings, wrong updates | Always provide unique keyExtractor |
Using <div> / <p> |
Crashes in React Native | Use <View> / <Text> |
Text outside <Text> |
Runtime error on Android | Wrap all text in <Text> |
| Mutating state directly | UI doesn't update | Use setter function from useState |
| No error handling in async | Silent failures | Always try/catch async operations |
| Ignoring safe areas | Content behind notch | Use SafeAreaView |
ScrollView with FlatList |
Performance issues | Use FlatList alone for large lists |
| Missing permissions check | App crashes | Always request and check permissions |
React Native vs alternatives
| Comparison | React Native | Flutter | Expo + RN | Native |
|---|---|---|---|---|
| Language | JavaScript/TypeScript | Dart | JavaScript/TypeScript | Swift/Kotlin |
| Learning curve | Low (if you know React) | Medium | Very low | High |
| Performance | Near-native | Near-native | Near-native | Native |
| Code sharing | ~95% | ~95% | ~95% | 0% |
| Ecosystem | Huge (npm) | Growing | Huge (npm) | Platform-specific |
| Hot reload | Yes | Yes | Yes | Limited |
| Native modules | Yes | Yes (FFI) | Via dev client | Yes |
| Web support | Limited (react-native-web) | Yes | Limited | No |
Learning path after this tutorial
| Next step | Resource |
|---|---|
| TypeScript in React Native | Add tsconfig.json, type your components |
| TanStack Query | Server state management, caching |
| Reanimated | Smooth animations and gestures |
| Expo Router | File-based navigation (like Next.js) |
| Testing | Jest + React Native Testing Library |
| Performance | Flashlist, memo, useCallback |
| Real-time | WebSockets, Firebase Realtime DB |
| Offline-first | WatermelonDB, MMKV |
FAQ
Do I need a Mac to build React Native apps? For iOS development and App Store publishing, yes — you need a Mac with Xcode (or use EAS Build cloud service). Android works on any OS.
Expo vs React Native CLI — which should I pick? Start with Expo. It handles setup, over-the-air updates, and cloud builds. Switch to the bare workflow only when you need a native module that Expo doesn't support.
Is React Native fast enough for production? Yes. Apps like Facebook, Instagram, Shopify, and Microsoft Teams use React Native. Performance bottlenecks are rare and usually fixable with Reanimated, Flashlist, or memoization.
Do I need to know React first? It helps but isn't mandatory. This tutorial assumes basic JavaScript. Learning React concepts (components, props, state, hooks) alongside React Native works well.
How do I handle different screen sizes?
Use percentage widths (width: '80%'), Dimensions.get('window'), useWindowDimensions(), and Flexbox. Avoid hardcoded pixel values.
What's the difference between TouchableOpacity and Pressable?
Pressable is newer (React Native 0.64+), more flexible with pressed state callbacks, and supports hitSlop. Use Pressable for new projects; TouchableOpacity still works fine.