React Native lets JavaScript developers build native mobile apps without learning Swift or Kotlin. The learning curve is real — mobile is different from the web — but the fundamentals are familiar if you know React.
This guide builds a real news reader app from scratch. You'll learn navigation, data fetching, and how React Native's layout system works.
Expo is the fastest way to start. It handles the native build tools so you can focus on JavaScript:
npx create-expo-app NewsReader --template blank-typescript
cd NewsReader
npx expo startInstall the Expo Go app on your phone, scan the QR code, and your app runs on your device. Live reload is instant.
Before writing code, internalize these key differences:
No HTML elements — use RN primitives:
<div> → <View><p>, <span> → <Text><img> → <Image><button> → <TouchableOpacity> or <Pressable><input> → <TextInput><ul>/<li> → <FlatList>No CSS classes — use StyleSheet.create():
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
paddingHorizontal: 16,
},
})Flexbox by default — but with flexDirection: 'column' as the default (opposite of web).
No px, em, rem — numbers are density-independent pixels:
fontSize: 16, // NOT '16px'
marginBottom: 8, // NOT '8px'NewsReader/
├── app/ # Expo Router pages
│ ├── _layout.tsx # Root layout
│ ├── index.tsx # Home screen
│ └── article/[id].tsx # Article detail
├── components/
│ ├── ArticleCard.tsx
│ └── LoadingSkeletons.tsx
├── hooks/
│ └── useNews.ts
└── types/
└── index.ts
// types/index.ts
export interface Article {
id: string
title: string
description: string
url: string
urlToImage: string | null
publishedAt: string
source: {
name: string
}
}// hooks/useNews.ts
import { useState, useEffect, useCallback } from 'react'
import type { Article } from '@/types'
const API_KEY = process.env.EXPO_PUBLIC_NEWS_API_KEY
const BASE_URL = 'https://newsapi.org/v2'
export function useNews(category = 'technology') {
const [articles, setArticles] = useState<Article[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const fetchNews = useCallback(async (isRefresh = false) => {
if (isRefresh) setRefreshing(true)
else setLoading(true)
setError(null)
try {
const res = await fetch(
`${BASE_URL}/top-headlines?country=us&category=${category}&apiKey=${API_KEY}`
)
if (!res.ok) throw new Error('Failed to fetch news')
const data = await res.json()
setArticles(data.articles)
} catch (e) {
setError(e instanceof Error ? e.message : 'Something went wrong')
} finally {
setLoading(false)
setRefreshing(false)
}
}, [category])
useEffect(() => {
fetchNews()
}, [fetchNews])
return { articles, loading, error, refreshing, refresh: () => fetchNews(true) }
}// components/ArticleCard.tsx
import { View, Text, Image, TouchableOpacity, StyleSheet } from 'react-native'
import { useRouter } from 'expo-router'
import type { Article } from '@/types'
interface Props {
article: Article
}
export default function ArticleCard({ article }: Props) {
const router = useRouter()
return (
<TouchableOpacity
style={styles.card}
onPress={() => router.push(`/article/${encodeURIComponent(article.url)}`)}
activeOpacity={0.7}
>
{article.urlToImage && (
<Image
source={{ uri: article.urlToImage }}
style={styles.image}
resizeMode="cover"
/>
)}
<View style={styles.content}>
<Text style={styles.source}>{article.source.name}</Text>
<Text style={styles.title} numberOfLines={2}>
{article.title}
</Text>
<Text style={styles.description} numberOfLines={3}>
{article.description}
</Text>
<Text style={styles.date}>
{new Date(article.publishedAt).toLocaleDateString()}
</Text>
</View>
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#fff',
borderRadius: 12,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 3, // Android shadow
overflow: 'hidden',
},
image: {
width: '100%',
height: 200,
},
content: {
padding: 16,
},
source: {
fontSize: 11,
fontWeight: '700',
color: '#3858F6',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 6,
},
title: {
fontSize: 16,
fontWeight: '700',
color: '#1a1a1a',
lineHeight: 22,
marginBottom: 8,
},
description: {
fontSize: 14,
color: '#666',
lineHeight: 20,
marginBottom: 8,
},
date: {
fontSize: 12,
color: '#999',
},
})// app/index.tsx
import { View, FlatList, StyleSheet, Text, ActivityIndicator } from 'react-native'
import { StatusBar } from 'expo-status-bar'
import ArticleCard from '@/components/ArticleCard'
import { useNews } from '@/hooks/useNews'
export default function HomeScreen() {
const { articles, loading, error, refreshing, refresh } = useNews()
if (loading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#3858F6" />
</View>
)
}
if (error) {
return (
<View style={styles.centered}>
<Text style={styles.errorText}>{error}</Text>
</View>
)
}
return (
<View style={styles.container}>
<StatusBar style="dark" />
<FlatList
data={articles}
keyExtractor={item => item.url}
renderItem={({ item }) => <ArticleCard article={item} />}
contentContainerStyle={styles.list}
refreshing={refreshing}
onRefresh={refresh}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<Text style={styles.header}>Tech News</Text>
}
/>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f5f5f5' },
centered: { flex: 1, alignItems: 'center', justifyContent: 'center' },
list: { padding: 16 },
header: {
fontSize: 28,
fontWeight: '800',
color: '#1a1a1a',
marginBottom: 20,
},
errorText: { fontSize: 16, color: '#e53e3e' },
})1. Forgetting flex: 1 on containers. Unlike web where divs have natural height, RN Views need flex: 1 to fill their parent.
2. Using % dimensions. Percentages work but cause inconsistencies across screen sizes. Use Dimensions.get('window') or the useWindowDimensions hook for responsive sizes.
3. Not handling the keyboard. On forms, the keyboard covers inputs. Wrap forms in KeyboardAvoidingView:
import { KeyboardAvoidingView, Platform } from 'react-native'
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
{/* form content */}
</KeyboardAvoidingView>4. Testing only on one platform. iOS and Android have real differences in shadows, fonts, gesture handling, and keyboard behavior. Test on both.
map in a ScrollView for large datasetsshadowColor/shadowOffset for iOS, elevation for Android shadows — they're different APIs