Blog App in React Native
Blog App
~13 Min. Lesezeit Zuletzt aktualisiert am August 8, 2026
The blog app is the CAPSTONE of this reference tutorial series and deliberately bundles especially many previously learned topics into one project: navigation with params (the "Passing Values Between Screens" chapter), Axios data fetching, loading states, pull-to-refresh, and a search field.
1. Description
Two screens via React Navigation: a LIST of all blog posts (loaded from a public test API) with search, and a DETAIL screen that receives the full post via route.params. Pull-to-refresh reloads the list via the native RefreshControl component.
2. Short example
<FlatList
data={posts}
refreshControl={
<RefreshControl refreshing={loading} onRefresh={reload} />
}
renderItem={({ item }) => <Text>{item.title}</Text>}
/>3. Complete project: a blog reader
npx create-expo-app blog-app
cd blog-app
npm install axios @react-navigation/native @react-navigation/native-stack
npx expo install react-native-screens react-native-safe-area-contextimport axios from 'axios';
const blogClient = axios.create({
baseURL: 'https://jsonplaceholder.typicode.com',
timeout: 8000,
});
export default blogClient;import { useState, useEffect, useCallback, useMemo } from 'react';
import {
View, Text, TextInput, FlatList, TouchableOpacity,
ActivityIndicator, RefreshControl, StyleSheet,
} from 'react-native';
import blogClient from '../api/blogClient';
function PostListScreen({ navigation }) {
const [posts, setPosts] = useState([]);
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [errorMessage, setErrorMessage] = useState(null);
const load = useCallback(async (isRefresh = false) => {
isRefresh ? setRefreshing(true) : setLoading(true);
setErrorMessage(null);
try {
const response = await blogClient.get('/posts', { params: { _limit: 20 } });
setPosts(response.data);
} catch (error) {
setErrorMessage('Failed to load posts.');
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const filtered = useMemo(
() => posts.filter((p) => p.title.toLowerCase().includes(query.toLowerCase())),
[posts, query]
);
if (loading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" />
</View>
);
}
return (
<View style={styles.container}>
<TextInput
style={styles.searchField}
placeholder="Search posts…"
value={query}
onChangeText={setQuery}
autoCapitalize="none"
/>
{errorMessage && <Text style={styles.error}>{errorMessage}</Text>}
<FlatList
data={filtered}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={() => load(true)} />
}
ListEmptyComponent={
<Text style={styles.empty}>No posts found.</Text>
}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.card}
onPress={() => navigation.navigate('Detail', item)}
>
<Text style={styles.postTitle} numberOfLines={2}>{item.title}</Text>
<Text style={styles.postPreview} numberOfLines={2}>{item.body}</Text>
</TouchableOpacity>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
centered: { flex: 1, alignItems: 'center', justifyContent: 'center' },
container: { flex: 1, paddingTop: 16, paddingHorizontal: 16 },
searchField: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 8, padding: 10, marginBottom: 12 },
error: { color: '#dc2626', marginBottom: 12, textAlign: 'center' },
list: { paddingBottom: 24 },
empty: { textAlign: 'center', marginTop: 40, color: '#9ca3af' },
card: { backgroundColor: '#f3f4f6', padding: 14, borderRadius: 10, marginBottom: 10 },
postTitle: { fontSize: 16, fontWeight: 'bold', textTransform: 'capitalize' },
postPreview: { color: '#6b7280', marginTop: 4 },
});
export default PostListScreen;import { ScrollView, Text, StyleSheet } from 'react-native';
function PostDetailScreen({ route }) {
const { title, body } = route.params;
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.text}>{body}</Text>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { padding: 20 },
title: { fontSize: 22, fontWeight: 'bold', marginBottom: 16, textTransform: 'capitalize' },
text: { fontSize: 16, lineHeight: 24, color: '#374151' },
});
export default PostDetailScreen;import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import PostListScreen from './screens/PostListScreen';
import PostDetailScreen from './screens/PostDetailScreen';
const Stack = createNativeStackNavigator();
export default function App() {
return (
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Posts" component={PostListScreen} />
<Stack.Screen name="Detail" component={PostDetailScreen} />
</Stack.Navigator>
</NavigationContainer>
);
}4. Explanation
load(isRefresh)deliberately drives TWO separate loading states (loadingfor the initial full-screen spinner,refreshingfor the pull-to-refresh indicator) from the same function – avoids code duplication without mixing up the two UI states.RefreshControlas therefreshControlprop ofFlatList(NOT as a separate element in the JSX!) is the only correct way to implement pull-to-refresh in a scrollable list.filteredviauseMemo– the SAME technique as in the "Search Bar" chapter, applied here to network data instead of a fixed list, showing the pattern's reusability.navigation.navigate('Detail', item)passes the ENTIRE post as params (the "Passing Values Between Screens" chapter) – the detail screen therefore needs NO network call of its own, the data is already fully available.numberOfLines={{2}}in the list visually truncates long text, WHILE the detail screen shows the samebodyvalue in full, untruncated, inside aScrollView.