March 27, 202610 min read

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.

react-native mobile javascript cross-platform tutorial
Ad 336x280

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

, you use . 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';

export default function ArticleCard({ article }) {
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>
);
}

const styles = StyleSheet.create({
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',
},
});

Key differences from web CSS: there's no cascading. Styles don't inherit (except 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

React Native is Flexbox all the way down. A few patterns handle 90% of layouts:

// Centering something
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
  <Text>Centered</Text>
</View>

// Horizontal row with space between
<View style={{ flexDirection: 'row', justifyContent: 'space-between', padding: 16 }}>
<Text>Left</Text>
<Text>Right</Text>
</View>

// Fill remaining space
<View style={{ flex: 1 }}>
<View style={{ height: 60 }}>{/ Fixed header /}</View>
<View style={{ flex: 1 }}>{/ This fills the rest /}</View>
</View>

The 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

For a news feed, you need an efficient list. 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';

export default function NewsFeed() {
const [articles, setArticles] = useState([]);
const [refreshing, setRefreshing] = useState(false);

const onRefresh = async () => {
setRefreshing(true);
await fetchArticles();
setRefreshing(false);
};

return (
<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.

Expo Router uses the file system for navigation. Let's set up our news reader with tabs:

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';

export default function TabLayout() {
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>
);
}

Navigate between screens using the router or Link:

import { Link, useRouter } from 'expo-router';

// Declarative
<Link href={/article/${article.id}}>
<ArticleCard article={article} />
</Link>

// Imperative
const router = useRouter();
router.push(/article/${article.id});

The article detail screen uses a dynamic route parameter:

// app/article/[id].tsx
import { useLocalSearchParams } from 'expo-router';

export default function ArticleScreen() {
const { id } = useLocalSearchParams();
const [article, setArticle] = useState(null);

useEffect(() => {
fetchArticle(id).then(setArticle);
}, [id]);

if (!article) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}

return (
<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

Let's connect to a real news API. We'll use a custom hook:

// hooks/useNews.ts
import { useState, useEffect } from 'react';

const API_KEY = 'your-api-key'; // Use a .env in production
const BASE_URL = 'https://newsapi.org/v2';

export function useTopHeadlines(category = 'general') {
const [articles, setArticles] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

const fetchArticles = async () => {
try {
setLoading(true);
const response = await fetch(
${BASE_URL}/top-headlines?country=us&category=${category}&apiKey=${API_KEY}
);
const data = await response.json();

if (data.status === 'ok') {
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);
}
};

useEffect(() => {
fetchArticles();
}, [category]);

return { articles, loading, error, refetch: fetchArticles };
}

Use it in the home screen:

// app/(tabs)/index.tsx
import { View, ActivityIndicator, Text } from 'react-native';
import { useTopHeadlines } from '../../hooks/useNews';
import NewsFeed from '../../components/NewsFeed';

export default function HomeScreen() {
const { articles, loading, error, refetch } = useTopHeadlines();

if (loading && articles.length === 0) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" color="#2563eb" />
</View>
);
}

if (error) {
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>
);
}

return <NewsFeed articles={articles} onRefresh={refetch} />;
}

State Management

For our bookmarks feature, we need state that persists across screens. React Context works well for this scale:

// context/BookmarkContext.tsx
import { createContext, useContext, useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';

const BookmarkContext = createContext(null);

export function BookmarkProvider({ children }) {
const [bookmarks, setBookmarks] = useState([]);

useEffect(() => {
loadBookmarks();
}, []);

const loadBookmarks = async () => {
try {
const stored = await AsyncStorage.getItem('bookmarks');
if (stored) setBookmarks(JSON.parse(stored));
} catch (err) {
console.error('Failed to load bookmarks', err);
}
};

const saveBookmarks = async (updated) => {
setBookmarks(updated);
await AsyncStorage.setItem('bookmarks', JSON.stringify(updated));
};

const addBookmark = (article) => {
if (!bookmarks.find(b => b.id === article.id)) {
saveBookmarks([...bookmarks, article]);
}
};

const removeBookmark = (articleId) => {
saveBookmarks(bookmarks.filter(b => b.id !== articleId));
};

const isBookmarked = (articleId) => {
return bookmarks.some(b => b.id === articleId);
};

return (
<BookmarkContext.Provider value={{ bookmarks, addBookmark, removeBookmark, isBookmarked }}>
{children}
</BookmarkContext.Provider>
);
}

export const useBookmarks = () => useContext(BookmarkContext);

Install AsyncStorage first:

npx expo install @react-native-async-storage/async-storage

Wrap your root layout with the provider:

// app/_layout.tsx
import { Stack } from 'expo-router';
import { BookmarkProvider } from '../context/BookmarkContext';

export default function RootLayout() {
return (
<BookmarkProvider>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="article/[id]" options={{ title: 'Article' }} />
</Stack>
</BookmarkProvider>
);
}

Now any screen can use useBookmarks() to save and retrieve articles.

Handling Platform Differences

Sometimes iOS and Android need different treatment:

import { Platform, StatusBar } from 'react-native';

const styles = StyleSheet.create({
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,
},
}),
},
});

For larger differences, you can create platform-specific files:

components/
  Header.ios.tsx    # iOS version
  Header.android.tsx # Android version

React Native automatically picks the right one based on the platform.

Building and Deploying

Running on a Physical Device

With Expo Go, you scan the QR code. But for a production build, you need EAS (Expo Application Services):

npm install -g eas-cli
eas login
eas build:configure

Build for both platforms:

# 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 production

Over-the-Air Updates

One of Expo's superpowers is OTA updates. Push JavaScript changes without going through the app store:

eas update --branch production --message "Fixed article loading bug"

Users get the update next time they open the app. No review process, no waiting.

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:
import { KeyboardAvoidingView, Platform } from 'react-native';

<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
{/ Your form here /}
</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.

What's Next

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.

Ad 728x90