React Native Tutorial: Build Your First Mobile App
Learn React Native with Expo. Build a news reader app with navigation, API calls, state management, and deploy to your device.
You know React. You've built web apps. Now someone asks if you can build a mobile app, and you think: do I really need to learn Swift and Kotlin? No. React Native lets you write mobile apps using the JavaScript and React knowledge you already have. The result isn't a wrapped website -- it's a real native app with native components.
We're going to build a news reader app from scratch using Expo, React Native's recommended toolchain. By the end, you'll have an app running on your phone that fetches real articles, has tab navigation, and looks like it belongs on a phone.
Setting Up with Expo
Expo handles the painful parts of React Native development: build configuration, native module linking, and device testing. You don't need Xcode or Android Studio to get started.
npx create-expo-app@latest NewsReader
cd NewsReader
npx expo start
Download the Expo Go app on your phone, scan the QR code, and your app is running on a real device. That's the magic moment.
Your project structure looks like this:
NewsReader/
app/
(tabs)/
_layout.tsx
index.tsx
explore.tsx
_layout.tsx
+not-found.tsx
assets/
components/
constants/
package.json
Expo Router (included by default) uses file-based routing, similar to Next.js. Files in the app/ directory become screens automatically.
Core Components
React Native doesn't use HTML elements. Instead of export default function ArticleCard({ article }) { const styles = StyleSheet.create({ Key differences from web CSS: there's no cascading. Styles don't inherit (except React Native is Flexbox all the way down. A few patterns handle 90% of layouts: // Horizontal row with space between // Fill remaining space The For a news feed, you need an efficient list. export default function NewsFeed() { const onRefresh = async () => { return ( Expo Router uses the file system for navigation. Let's set up our news reader with tabs: export default function TabLayout() { Navigate between screens using the // Declarative // Imperative The article detail screen uses a dynamic route parameter: export default function ArticleScreen() { useEffect(() => { if (!article) { return ( Let's connect to a real news API. We'll use a custom hook: const API_KEY = 'your-api-key'; // Use a .env in production export function useTopHeadlines(category = 'general') { const fetchArticles = async () => { if (data.status === 'ok') { useEffect(() => { return { articles, loading, error, refetch: fetchArticles }; Use it in the home screen: export default function HomeScreen() { if (loading && articles.length === 0) { if (error) { return <NewsFeed articles={articles} onRefresh={refetch} />; For our bookmarks feature, we need state that persists across screens. React Context works well for this scale: const BookmarkContext = createContext(null); export function BookmarkProvider({ children }) { useEffect(() => { const loadBookmarks = async () => { const saveBookmarks = async (updated) => { const addBookmark = (article) => { const removeBookmark = (articleId) => { const isBookmarked = (articleId) => { return ( export const useBookmarks = () => useContext(BookmarkContext); Install AsyncStorage first: Wrap your root layout with the provider: export default function RootLayout() { Now any screen can use Sometimes iOS and Android need different treatment: const styles = StyleSheet.create({ For larger differences, you can create platform-specific files: React Native automatically picks the right one based on the platform. With Expo Go, you scan the QR code. But for a production build, you need EAS (Expo Application Services): Build for both platforms: One of Expo's superpowers is OTA updates. Push JavaScript changes without going through the app store: Users get the update next time they open the app. No review process, no waiting. <KeyboardAvoidingView You've built a functional mobile app with navigation, API integration, local storage, and platform-aware styling. The natural next steps are adding push notifications with Expo Notifications, implementing deep linking, adding animations with React Native Reanimated, and exploring more advanced state management with Zustand or Redux Toolkit. For more React Native projects and guided practice, check out CodeUp.. Instead of , you use . Instead of , you use . This feels weird for about an hour, then becomes natural.
import { View, Text, Image, StyleSheet } from 'react-native';
return (
<View style={styles.card}>
<Image
source={{ uri: article.imageUrl }}
style={styles.image}
/>
<View style={styles.content}>
<Text style={styles.title}>{article.title}</Text>
<Text style={styles.source}>{article.source}</Text>
<Text style={styles.date}>{article.publishedAt}</Text>
</View>
</View>
);
}
card: {
backgroundColor: '#fff',
borderRadius: 12,
marginHorizontal: 16,
marginVertical: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3, // Android shadow
},
image: {
width: '100%',
height: 200,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
},
content: {
padding: 16,
},
title: {
fontSize: 18,
fontWeight: '700',
color: '#1a1a1a',
marginBottom: 8,
},
source: {
fontSize: 14,
color: '#666',
marginBottom: 4,
},
date: {
fontSize: 12,
color: '#999',
},
});Text inside Text). Everything uses Flexbox by default, and the default flexDirection is column, not row. No CSS Grid. All units are density-independent pixels, not px or rem.Styling: The Flexbox Mental Model
// Centering something
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Centered</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', padding: 16 }}>
<Text>Left</Text>
<Text>Right</Text>
</View>
<View style={{ flex: 1 }}>
<View style={{ height: 60 }}>{/ Fixed header /}</View>
<View style={{ flex: 1 }}>{/ This fills the rest /}</View>
</View>flex: 1 property means "take all available space." If two siblings both have flex: 1, they split the space equally. flex: 2 on one means it takes twice the space of flex: 1.Lists: FlatList vs ScrollView
ScrollView renders everything at once, which is fine for a few items but catastrophic for hundreds. FlatList renders only what's on screen.import { FlatList, RefreshControl } from 'react-native';
const [articles, setArticles] = useState([]);
const [refreshing, setRefreshing] = useState(false);
setRefreshing(true);
await fetchArticles();
setRefreshing(false);
};
<FlatList
data={articles}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <ArticleCard article={item} />}
contentContainerStyle={{ paddingVertical: 8 }}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
ListEmptyComponent={
<View style={{ padding: 32, alignItems: 'center' }}>
<Text style={{ color: '#999' }}>No articles yet</Text>
</View>
}
/>
);
}FlatList gives you pull-to-refresh, empty states, headers, footers, and separator components for free. Always use it for dynamic lists.
Navigation with Expo Router
app/
(tabs)/
_layout.tsx # Tab configuration
index.tsx # Home / Top News
categories.tsx # Browse by category
bookmarks.tsx # Saved articles
article/
[id].tsx # Article detail screen
_layout.tsx # Root layout// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#2563eb',
headerStyle: { backgroundColor: '#fff' },
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Top News',
tabBarIcon: ({ color, size }) => (
<Ionicons name="newspaper-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="categories"
options={{
title: 'Categories',
tabBarIcon: ({ color, size }) => (
<Ionicons name="grid-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="bookmarks"
options={{
title: 'Saved',
tabBarIcon: ({ color, size }) => (
<Ionicons name="bookmark-outline" size={size} color={color} />
),
}}
/>
</Tabs>
);
}router or Link:import { Link, useRouter } from 'expo-router';
<Link href={/article/${article.id}}>
<ArticleCard article={article} />
</Link>
const router = useRouter();
router.push(/article/${article.id});// app/article/[id].tsx
import { useLocalSearchParams } from 'expo-router';
const { id } = useLocalSearchParams();
const [article, setArticle] = useState(null);
fetchArticle(id).then(setArticle);
}, [id]);
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
<ScrollView style={{ flex: 1, backgroundColor: '#fff' }}>
<Image source={{ uri: article.imageUrl }} style={{ width: '100%', height: 250 }} />
<View style={{ padding: 20 }}>
<Text style={{ fontSize: 24, fontWeight: '700', marginBottom: 12 }}>
{article.title}
</Text>
<Text style={{ fontSize: 14, color: '#666', marginBottom: 20 }}>
{article.source} -- {article.publishedAt}
</Text>
<Text style={{ fontSize: 16, lineHeight: 26, color: '#333' }}>
{article.content}
</Text>
</View>
</ScrollView>
);
}Fetching Data from an API
// hooks/useNews.ts
import { useState, useEffect } from 'react';
const BASE_URL = 'https://newsapi.org/v2';
const [articles, setArticles] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
try {
setLoading(true);
const response = await fetch(
${BASE_URL}/top-headlines?country=us&category=${category}&apiKey=${API_KEY}
);
const data = await response.json();
setArticles(data.articles.map((a, i) => ({
id: ${category}-${i},
title: a.title,
source: a.source.name,
imageUrl: a.urlToImage,
publishedAt: new Date(a.publishedAt).toLocaleDateString(),
content: a.content,
url: a.url,
})));
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchArticles();
}, [category]);
}// app/(tabs)/index.tsx
import { View, ActivityIndicator, Text } from 'react-native';
import { useTopHeadlines } from '../../hooks/useNews';
import NewsFeed from '../../components/NewsFeed';
const { articles, loading, error, refetch } = useTopHeadlines();
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" color="#2563eb" />
</View>
);
}
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 }}>
<Text style={{ color: '#dc2626', fontSize: 16 }}>Failed to load news</Text>
<Text style={{ color: '#999', marginTop: 8 }}>{error}</Text>
</View>
);
}
}State Management
// context/BookmarkContext.tsx
import { createContext, useContext, useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
const [bookmarks, setBookmarks] = useState([]);
loadBookmarks();
}, []);
try {
const stored = await AsyncStorage.getItem('bookmarks');
if (stored) setBookmarks(JSON.parse(stored));
} catch (err) {
console.error('Failed to load bookmarks', err);
}
};
setBookmarks(updated);
await AsyncStorage.setItem('bookmarks', JSON.stringify(updated));
};
if (!bookmarks.find(b => b.id === article.id)) {
saveBookmarks([...bookmarks, article]);
}
};
saveBookmarks(bookmarks.filter(b => b.id !== articleId));
};
return bookmarks.some(b => b.id === articleId);
};
<BookmarkContext.Provider value={{ bookmarks, addBookmark, removeBookmark, isBookmarked }}>
{children}
</BookmarkContext.Provider>
);
}npx expo install @react-native-async-storage/async-storage// app/_layout.tsx
import { Stack } from 'expo-router';
import { BookmarkProvider } from '../context/BookmarkContext';
return (
<BookmarkProvider>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="article/[id]" options={{ title: 'Article' }} />
</Stack>
</BookmarkProvider>
);
}useBookmarks() to save and retrieve articles.Handling Platform Differences
import { Platform, StatusBar } from 'react-native';
container: {
paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
android: {
elevation: 3,
},
}),
},
});components/
Header.ios.tsx # iOS version
Header.android.tsx # Android versionBuilding and Deploying
Running on a Physical Device
npm install -g eas-cli
eas login
eas build:configure# Development build (installable APK/IPA for testing)
eas build --platform android --profile development
eas build --platform ios --profile development
# Production build
eas build --platform all --profile productionOver-the-Air Updates
eas update --branch production --message "Fixed article loading bug"Common Mistakes
Using ScrollView for long lists. Your app will lag horribly. Use FlatList or SectionList for any list that could grow beyond what fits on screen.
Forgetting flex: 1 on containers. If your screen appears blank, the parent probably has zero height. Add flex: 1 to make it fill available space.
Styling with percentages everywhere. Unlike web, percentage-based sizing in React Native is unreliable in some contexts. Use flex properties and Dimensions.get('window') for responsive layouts.
Ignoring the keyboard. On forms, the keyboard covers input fields. Wrap your form in KeyboardAvoidingView:
Not handling loading and error states. Every API call needs three states: loading, success, error. Users on mobile are especially impatient with blank screens.
Testing only on the simulator. Real devices have different performance characteristics, gesture handling, and keyboard behavior. Test on a real phone regularly.
import { KeyboardAvoidingView, Platform } from 'react-native';
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
{/ Your form here /}
</KeyboardAvoidingView>What's Next